Skip to content

Conversation

rintaro
Copy link
Member

@rintaro rintaro commented Sep 26, 2025

Consider the following code:

}
@attr1 func foo() {}

Previously, this was parsed as a FunctionDeclSyntax with the unexpected code } @attr attached as unexpectedBeforeFuncKeyword. This was problematic because @attr was effectively ignored. The TokenPrecedence-based recovery strategy couldn't handle this well, since @ is not a declaration keyword and doesn't by itself signal the start of a declaration.

One option would be to check atStartOfDeclaration() after each skipSingle() and recognize @ as the start of a declaration. However, then the preceding } would need to be attached to the first attribute in the attribute list, which would require threading recovery logic into parseAttributeList() and only applying it to the first parseAttribute(). This would make the code more complex and harder to maintain.

This PR introduces a new syntax node UnexpectedCodeDeclSyntax

  • It represents unexpected code at the statement/declaration level.
  • All statement-level recovery is now handled by parseUnexpectedCodeDeclaration().
    This simplifies recovery handling significantly.

Also:

  • Improve #if related recovery:
    • orphan #endif et al at top-level is now UnexpectedCodeDecl instead of making everything after it "unexpected"
      #endif
      func foo() {}
    • Unbalanced #endif et al closes code/member block with missing }.
      struct S {
        #if FOO
        func foo() {
        #endif
      }
      
    • Simplified parsePoundDirective(). It now receives only one closure instead of 3.
  • Don't attach ';' to compiler directives. For example:
    #sourceLocation(file: "<file>", line: 100)
    ;
    ; is now diagnosed as ;-only statement, or unexpected ; separator depending on the position.
  • Adjust TokenPrecedence for decl/statement keyword so. Decl keyword recovery won't go beyond } or statements. E.g.
    func foo() {
      @attr
    }
    struct S {}
    func foo() {
      @attr
      if true {}
      struct S {}
    }
  • cont...

@rintaro rintaro force-pushed the parser-recover-stmtlevel branch from c706d4c to 2ca55fe Compare September 26, 2025 23:27
_ = subparser.consumeAttributeList()
hasAttribute = true
} else if subparser.at(.poundIf) && subparser.consumeIfConfigOfAttributes() {
subparser.skipSingle()
Copy link
Member Author

Choose a reason for hiding this comment

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

subparser.consumeIfConfigOfAttributes() already skipped #if .... #endif

@rintaro rintaro force-pushed the parser-recover-stmtlevel branch from ef1bf91 to dc4e479 Compare September 27, 2025 03:58
base: .syntax,
nameForDiagnostics: "declaration",
parserFunction: "parseDeclaration"
parserFunction: "parseDeclarationOrIfConfig"
Copy link
Member Author

Choose a reason for hiding this comment

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

parseDeclaration() don't parse #if ... #endif. Newly introduced parseDeclarationOrIfConfig() just for DeclSyntax.parse(from:)

mutating func atStartOfDeclaration(
isAtTopLevel: Bool = false,
allowInitDecl: Bool = true,
allowRecovery: Bool = false
Copy link
Member Author

Choose a reason for hiding this comment

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

Removed allowRecovery from atStartOfDeclaration()/atStartOfStatement()/atStartOfSwitchCase() because all statement-level recoveries are now done by parseUnexpectedCodeDecl()

mutating func parseDeclaration(in context: DeclarationParseContext = .topLevelOrCodeBlock) -> RawDeclSyntax {
// If we are at a `#if` of attributes, the `#if` directive should be
// parsed when we're parsing the attributes.
if self.at(.poundIf) && !self.withLookahead({ $0.consumeIfConfigOfAttributes() }) {
Copy link
Member Author

Choose a reason for hiding this comment

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

This block is basically hoisted to parseMemberBlockItem()

  • To align with parseCodeBlockItem() scheme
  • Don't want to attach ; to IfConfigDeclSyntax

Comment on lines -383 to -388
let isProbablyVarDecl = self.at(.identifier, .wildcard) && self.peek(isAt: .colon, .equal, .comma)
let isProbablyTupleDecl = self.at(.leftParen) && self.peek(isAt: .identifier, .wildcard)

if isProbablyVarDecl || isProbablyTupleDecl {
return RawDeclSyntax(self.parseBindingDeclaration(attrs, .missing(.keyword(.var)), in: context))
}
Copy link
Member Author

Choose a reason for hiding this comment

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

Factored out to atBindingDeclarationWithoutVarKeyword() and move the handling to recoveryResult above, aligning with atFunctionDeclarationWithoutFuncKeyword().

Comment on lines -401 to -404

if atFunctionDeclarationWithoutFuncKeyword() {
return RawDeclSyntax(self.parseFuncDeclaration(attrs, .missing(.keyword(.func))))
}
Copy link
Member Author

Choose a reason for hiding this comment

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

This is already handled in recoveryResult above.

if self.atStartOfStatement(preferExpr: true) || self.atStartOfDeclaration() {
return false
}
if self.atStartOfLine && self.withLookahead({ $0.atStartOfSwitchCase() }) {
Copy link
Member Author

Choose a reason for hiding this comment

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

Previously case was a start of statement, but not anymore. This prevents the second case below is parsed as the returning expression.

case .foo:
  return
case bar:
  ...


let item: RawCodeBlockItemSyntax.Item
let attachSemi: Bool
if self.at(.poundIf) && !self.withLookahead({ $0.consumeIfConfigOfAttributes() }) {
Copy link
Member Author

Choose a reason for hiding this comment

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

Inlined parseItem() because we want to choose whether to attach ; (attachSemi) depending on what we parse.

Comment on lines +206 to +213
} else {
// Otherwise, eat the unexpected tokens into an "decl".
item = .decl(
RawDeclSyntax(
self.parseUnexpectedCodeDeclaration(allowInitDecl: allowInitDecl, requiresDecl: false, until: stopCondition)
)
)
attachSemi = true
Copy link
Member Author

@rintaro rintaro Sep 27, 2025

Choose a reason for hiding this comment

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

Previously in parseItem(), we tried atStartOfStatement(allowRecovery: true) and atStartOfDeclaration(allowRecovery: true) for recoveries.
Now unexpected tokens at this position are always parsed as UnexpectedCodeDeclSyntax until we find a decl, statement, or expression.

1 | func o() {
2 | }👨‍👩‍👧‍👦}
| |`- error: extraneous braces at top level
| |`- error: unexpected braces in source file
Copy link
Member Author

Choose a reason for hiding this comment

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

This message change is because previously the UnexpectedNodesSyntax was stored as SourceFileSyntax.unexpectedBetweenStatementsAndEndOfFileToken, but now it's UnexpectedCodeDeclSyntax in SourceFileSyntax.statements.

DiagnosticSpec(
locationMarker: "2️⃣",
message: "unexpected code '))' before macro"
message: "unexpected code '))' in class"
Copy link
Member Author

Choose a reason for hiding this comment

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

This and similar changes are because the unexpected code are now separate UnexpectedCodeDeclSyntax in the statements.

Copy link
Contributor

Choose a reason for hiding this comment

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

So IIUC this parses as an attribute on a class with a macro member? The change from unexpected to a node seems fine to me diagnostic wise, there's not much difference in the "before" vs "in"

@rintaro rintaro force-pushed the parser-recover-stmtlevel branch from 5ceb6e8 to f9a97cf Compare September 28, 2025 03:08
@rintaro rintaro marked this pull request as ready for review September 29, 2025 05:16
Copy link
Contributor

@hamishknight hamishknight left a comment

Choose a reason for hiding this comment

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

Nice cleanup!

DiagnosticSpec(
locationMarker: "2️⃣",
message: "unexpected code '))' before macro"
message: "unexpected code '))' in class"
Copy link
Contributor

Choose a reason for hiding this comment

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

So IIUC this parses as an attribute on a class with a macro member? The change from unexpected to a node seems fine to me diagnostic wise, there's not much difference in the "before" vs "in"

),
DiagnosticSpec(locationMarker: "2️⃣", message: "unexpected code in source file"),
],
fixedSource: """
Copy link
Contributor

Choose a reason for hiding this comment

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

Why does the fixed source still have the #endif and extra }? Shouldn't both be unexpected and thus removed in the fixed source? Is the new UnexpectedDeclNode not being treated the same as unexpected?

I'm also unsure about closing the struct at the endif. It makes sense if there's a matching #if, but when there isn't... 🤔

Copy link
Member Author

Choose a reason for hiding this comment

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

Diagnostics are conservative about removing user code. UnexpectedNodesSyntax in general might contain important user code and we don't want to delete them in "fix-it all"

Copy link
Member Author

Choose a reason for hiding this comment

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

I'm also unsure about closing the struct at the endif.

We can't know if there's } after #endif until we parse it.
IMO #endif et al should always be stronger than }, because, unlike { or } which editor often insert, the user probably intentionally wrote #endif for some reason.

This specific test case might be controversial, but for example, for a source file like this:

struct MyStruct {
  func foo() {}
  // ...
#endif

struct Other {
....
}

This is probably missing } before #endif

diagnostics: [
DiagnosticSpec(
locationMarker: "1️⃣",
// FIXME: "expected attribute name after '@'".
Copy link
Contributor

Choose a reason for hiding this comment

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

Could you file an issue for this rather than have the FIXME? Or add a specific test for the message, with the FIXME + a link to the issue? Same for the one below

Copy link
Member Author

Choose a reason for hiding this comment

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

sure. filed
#3158
#3159

)
}

func testDeclSyntaxParseIfConfigAttr() throws {
Copy link
Contributor

Choose a reason for hiding this comment

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

Thanks for adding these 🙇

Comment on lines +291 to +292
message: "expected declaration and ';' after attribute",
fixIts: ["insert declaration and ';'"]
Copy link
Contributor

Choose a reason for hiding this comment

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

Why do we default to ; instead of a newline for this case?

Copy link
Member Author

Choose a reason for hiding this comment

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

This is modeled as

CodeBlockItem(
  item: .decl(MissingDecl(...)),
  semicolon: .semiColonToken(precence: .missing)
)

And ParseDiagnosticsGenerator generates single diagnostic for consecutive missing nodes.

"consecutive statements ... insert newline or ';'" is a special diagnostics that is generated when the previous node was not missing.

@rintaro
Copy link
Member Author

rintaro commented Oct 3, 2025

swiftlang/swift#84553
@swift-ci Please test

@rintaro
Copy link
Member Author

rintaro commented Oct 3, 2025

swiftlang/swift#84553
@swift-ci Please test Windows

1 similar comment
@rintaro
Copy link
Member Author

rintaro commented Oct 3, 2025

swiftlang/swift#84553
@swift-ci Please test Windows

@rintaro rintaro merged commit 8ea19b6 into swiftlang:main Oct 3, 2025
34 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants