Skip to content

Typed throws #2219

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 17 commits into from
Sep 30, 2023
Merged

Typed throws #2219

merged 17 commits into from
Sep 30, 2023

Conversation

DougGregor
Copy link
Member

Parsing of typed throw specifiers. See swiftlang/swift#68629 for more information.

Copy link
Member

@ahoppen ahoppen left a comment

Choose a reason for hiding this comment

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

All of this should be behind an experimental language feature. Let me know if you need help with that as you get closer to actually wanting to merge it.

@DougGregor
Copy link
Member Author

All of this should be behind an experimental language feature.

Great point, and it was super-easy to do. Thanks!

@DougGregor DougGregor mentioned this pull request Sep 22, 2023
21 tasks
Comment on lines 83 to 85
kind: .thrownType,
base: .syntax,
nameForDiagnostics: "thrown type",
Copy link
Member

Choose a reason for hiding this comment

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

Comment on lines 123 to 133
Child(
name: "thrownType",
kind: .node(kind: .thrownType),
isOptional: true
),
Copy link
Member

Choose a reason for hiding this comment

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

We need to mark this child as experimental. There is no infrastructure for it yet, though. Could you write it?

Same for all the other thrownType children.


Could you add documentation for these children?

@@ -56,7 +56,13 @@ public let TRAITS: [Trait] = [
Child(name: "asyncSpecifier", kind: .token(choices: [.keyword(.async), .keyword(.reasync)]), isOptional: true),
Child(name: "unexpectedBetweenAsyncSpecifierAndThrowsSpecifier", kind: .node(kind: .unexpectedNodes), isOptional: true),
Child(name: "throwsSpecifier", kind: .token(choices: [.keyword(.throws), .keyword(.rethrows)]), isOptional: true),
Child(name: "unexpectedAfterThrowsSpecifier", kind: .node(kind: .unexpectedNodes), isOptional: true),
Child(name: "unexpectedBetweenThrowsSpecifierAndThrownType", kind: .node(kind: .unexpectedNodes), isOptional: true),
Copy link
Member

Choose a reason for hiding this comment

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

This is an API-breaking change that we should mention in the API-Incompatible Changes section of the release notes. I think it’s fine to break the API here since it’s unlikely that this property is in considerable use.

@@ -79,6 +79,27 @@ public let COMMON_NODES: [Node] = [
]
),

Node(
kind: .thrownType,
base: .syntax,
Copy link
Member

Choose a reason for hiding this comment

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

Could you add a doc comments for this node and its children?

@@ -79,6 +79,27 @@ public let COMMON_NODES: [Node] = [
]
),

Node(
kind: .thrownType,
Copy link
Member

Choose a reason for hiding this comment

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

The more consistent naming of this node would be thrownTypeClause in my opinion since it’s more than just the type (it also has parentheses).

Comment on lines 657 to 659
if throwsKeyword != nil && self.at(.leftParen) && experimentalFeatures.contains(.typedThrows) {
thrownType = parseThrownType()
}
Copy link
Member

Choose a reason for hiding this comment

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

IIUC this would also try to parse a thrown type for try async(MyError), which I think is confusing. IMO we should parse the thrown type whenever we set the throwsKeyword

Comment on lines +801 to +813
if effect.spec.isThrowsSpecifier && self.peek().rawTokenKind == .leftParen {
var backtrack = self.lookahead()
backtrack.consumeAnyToken()
backtrack.skipSingle()
return backtrack.atFunctionTypeArrow()
}
Copy link
Member

Choose a reason for hiding this comment

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

I think this doesn’t handle async throws(MyError) because the current token is async here, which means that we end up in the if below, which just consumes throws but not MyError.

Copy link
Member Author

Choose a reason for hiding this comment

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

The consumeAnyToken() consumes the throws, and the skipSingle consumes a parenthesized expression.

Copy link
Member

Choose a reason for hiding this comment

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

No, it doesn’t. The following test fails to parse

assertParse(
  "[() async throws(MyError) -> Void]()"
)

@@ -279,9 +279,9 @@ public class ParseDiagnosticsGenerator: SyntaxAnyVisitor {
}
}

if let throwsSpecifier = node.throwsSpecifier {
if let throwsSpecifier = node.throwsSpecifier, node.thrownType == nil {
Copy link
Member

Choose a reason for hiding this comment

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

Why do we need to check that thrownType is nil here? We should be able to handle throws(MyError) async as well.

Copy link
Member Author

Choose a reason for hiding this comment

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

We don't need to do the check

"[() throws(PosixError) -> Void]()",
experimentalFeatures: [.typedThrows]
)
}
Copy link
Member

Choose a reason for hiding this comment

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

I think the following test cases would be interesting as well. I have not filled in the expected diagnostics here but we should make sure that they make sense

Function parsing.

assertParse(
  "func test() throws(MyError) async {}"
)
assertParse(
  "func test() throws async(MyError) {}"
)
assertParse(
  "func test() try(MyError) async {}"
)
assertParse(
  "func test() try async(MyError) {}"
)
assertParse(
  "func test() throws(MyError) await {}"
)
assertParse(
  "func test() throws await(MyError) {}"
)
assertParse(
  "func test() try(MyError) await {}"
)
assertParse(
  "func test() try await(MyError) {}"
)
assertParse(
  "func test() async(MyError) {}"
)
assertParse(
  "func test() await(MyError) {}"
)
assertParse(
  "func test() try(MyError) {}"
)
assertParse(
  "func test() throws(MyError) {}"
)
assertParse(
  "func test() throws(MyError)async {}" // no space between closing parenthesis and `async`
)

isAtFunctionTypeArrow disambiguation

assertParse(
  "[() throws(MyError) async -> Void]()"
)
assertParse(
  "[() throws async(MyError) -> Void]()"
)
assertParse(
  "[() try(MyError) async -> Void]()"
)
assertParse(
  "[() try async(MyError) -> Void]()"
)
assertParse(
  "[() throws(MyError) await -> Void]()"
)
assertParse(
  "[() throws await(MyError) -> Void]()"
)
assertParse(
  "[() try(MyError) await -> Void]()"
)
assertParse(
  "[() try await(MyError) -> Void]()"
)
assertParse(
  "[() async(MyError) -> Void]()"
)
assertParse(
  "[() await(MyError) -> Void]()"
)
assertParse(
  "[() try(MyError) -> Void]()"
)
assertParse(
  "[() throws(MyError) -> Void]()"
)

Copy link
Member Author

Choose a reason for hiding this comment

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

Great test ideas, thanks!

@DougGregor DougGregor marked this pull request as ready for review September 29, 2023 21:21
@DougGregor
Copy link
Member Author

swiftlang/swift#68629

@swift-ci please test

DiagnosticSpec(locationMarker: "1️⃣", message: "'async' must precede 'throws'", fixIts: ["move 'async' in front of 'throws'"]),
DiagnosticSpec(locationMarker: "2️⃣", message: "unexpected code '(MyError)' in function"),
],
fixedSource: "func test() async throws (MyError) {}",
Copy link
Contributor

Choose a reason for hiding this comment

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

@ahoppen are we missing removing (MyError) here because they end up intersecting?

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 think it's grabbing both the async and the unexpected nodes after the async and moving them forward.

Copy link
Member

Choose a reason for hiding this comment

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

That’s intentional and behaves correctly. (MyError) gets parsed as unexpected tokens (which is fine, I think). And we don’t provide a Fix-It to remove unexpected tokens because most likely you wrote them for a reason and just removing something you wrote should not be a default fixing strategy.

"func test() 1️⃣try2️⃣(MyError) async {}",
diagnostics: [
DiagnosticSpec(locationMarker: "1️⃣", message: "expected throwing specifier; did you mean 'throws'?", fixIts: ["replace 'try' with 'throws'"]),
DiagnosticSpec(locationMarker: "2️⃣", message: "unexpected code '(MyError) async' in function"),
Copy link
Contributor

Choose a reason for hiding this comment

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

I'm surprised this isn't handled as it is above when try is the proper throws or below where try is before async, ie. that async is unexpected here.

Copy link
Member Author

Choose a reason for hiding this comment

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

It's because I am not parsing the thrown type clause along a recovery path, I think.

Copy link
Member

Choose a reason for hiding this comment

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

We should parse the thrown type clause along the recovery path.

assertParse(
"func test() throws(MyError) 1️⃣await {}",
diagnostics: [
DiagnosticSpec(locationMarker: "1️⃣", message: "'await' must precede 'throws'", fixIts: ["move 'await' in front of 'throws'"])
Copy link
Contributor

Choose a reason for hiding this comment

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

No expected async specifier; did you mean 'async'? 🤔? The fixed source is correct though so... that's good. This is the case for a few of the tests.

Copy link
Member Author

Choose a reason for hiding this comment

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

There's some comments in the diagnostics generation about how the later errors subsume the earlier ones.

Copy link
Member

Choose a reason for hiding this comment

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

This should be relatively easy to fix by changing the creation of missingToken for misspelled tokens in parseEffectsSpecifier like

asyncKeyword = missingToken(misspelledAsync.tokenKind, text: misspelledAsync.tokenText)

diagnostics: [
DiagnosticSpec(message: "'async' must precede 'throws'", fixIts: ["move 'async' in front of 'throws'"])
],
fixedSource: "func test() async throws(MyError){}",
Copy link
Contributor

Choose a reason for hiding this comment

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

Very nitpicky, but it would be good to keep the space before the {.

@DougGregor
Copy link
Member Author

swiftlang/swift#68629

@swift-ci please test

@DougGregor
Copy link
Member Author

swiftlang/swift#68629

@swift-ci please test Windows

@DougGregor
Copy link
Member Author

swiftlang/swift#68629

@swift-ci please test

1 similar comment
@DougGregor
Copy link
Member Author

swiftlang/swift#68629

@swift-ci please test

@DougGregor
Copy link
Member Author

swiftlang/swift#68629

@swift-ci please test

@DougGregor
Copy link
Member Author

swiftlang/swift#68629

@swift-ci please test Windows

@DougGregor
Copy link
Member Author

swiftlang/swift#68629

@swift-ci please test

@DougGregor
Copy link
Member Author

swiftlang/swift#68629

@swift-ci please test Windows

@DougGregor
Copy link
Member Author

swiftlang/swift#68629

@swift-ci please test Linux

@DougGregor DougGregor merged commit f974452 into swiftlang:main Sep 30, 2023
@DougGregor DougGregor deleted the typed-throws branch September 30, 2023 18:18
@@ -79,6 +79,34 @@ public let COMMON_NODES: [Node] = [
]
),

Node(
kind: .thrownTypeClause,
Copy link
Member

Choose a reason for hiding this comment

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

You should mark this node as experimental.

Comment on lines +801 to +813
if effect.spec.isThrowsSpecifier && self.peek().rawTokenKind == .leftParen {
var backtrack = self.lookahead()
backtrack.consumeAnyToken()
backtrack.skipSingle()
return backtrack.atFunctionTypeArrow()
}
Copy link
Member

Choose a reason for hiding this comment

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

No, it doesn’t. The following test fails to parse

assertParse(
  "[() async throws(MyError) -> Void]()"
)

Comment on lines +262 to +263
node.unexpectedBeforeAsyncSpecifier, node.unexpectedBetweenAsyncSpecifierAndThrowsSpecifier, node.unexpectedBetweenThrowsSpecifierAndThrownError,
node.unexpectedAfterThrownError,
Copy link
Member

Choose a reason for hiding this comment

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

Could you wrap this array literal so that there’s one element per line. It just reads nicer that way.

Comment on lines +736 to +745

extension EffectSpecifiersSyntax {
// Default implementation for the experimental thrownError child, which must
// be provided due to the use of @_spi.
@_spi(ExperimentalLanguageFeatures)
public var thrownError: ThrownTypeClauseSyntax? {
get { return nil }
set {}
}
}
Copy link
Member

Choose a reason for hiding this comment

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

SyntaxProtocol.swift really doesn’t seem like the right place to put this. Also, AFAICT everything compiles fine even if I remove this.

DiagnosticSpec(locationMarker: "1️⃣", message: "'async' must precede 'throws'", fixIts: ["move 'async' in front of 'throws'"]),
DiagnosticSpec(locationMarker: "2️⃣", message: "unexpected code '(MyError)' in function"),
],
fixedSource: "func test() async throws (MyError) {}",
Copy link
Member

Choose a reason for hiding this comment

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

That’s intentional and behaves correctly. (MyError) gets parsed as unexpected tokens (which is fine, I think). And we don’t provide a Fix-It to remove unexpected tokens because most likely you wrote them for a reason and just removing something you wrote should not be a default fixing strategy.

assertParse(
"func test() throws(MyError) 1️⃣await {}",
diagnostics: [
DiagnosticSpec(locationMarker: "1️⃣", message: "'await' must precede 'throws'", fixIts: ["move 'await' in front of 'throws'"])
Copy link
Member

Choose a reason for hiding this comment

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

This should be relatively easy to fix by changing the creation of missingToken for misspelled tokens in parseEffectsSpecifier like

asyncKeyword = missingToken(misspelledAsync.tokenKind, text: misspelledAsync.tokenText)

"func test() 1️⃣try2️⃣(MyError) await {}",
diagnostics: [
DiagnosticSpec(locationMarker: "1️⃣", message: "expected throwing specifier; did you mean 'throws'?", fixIts: ["replace 'try' with 'throws'"]),
DiagnosticSpec(locationMarker: "2️⃣", message: "unexpected code '(MyError) await' in function"),
Copy link
Member

Choose a reason for hiding this comment

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

We should be parsing (MyError) as a thrown type clause here or at least recover to parse await as a misspelled async keyword. No matter how we implement it, the Fix-It that should be produced, is to move await in front of try.

DiagnosticSpec(locationMarker: "1️⃣", message: "expected throwing specifier; did you mean 'throws'?", fixIts: ["replace 'try' with 'throws'"]),
DiagnosticSpec(locationMarker: "2️⃣", message: "unexpected code '(MyError)' in function"),
],
fixedSource: "func test() throws (MyError) {}",
Copy link
Member

Choose a reason for hiding this comment

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

We should change BasicFormat to not insert a space here. BasicFormat.requiresWhitespace is then entry point where this is decided.

fixedSource: "func test() async throws(MyError) {}",
experimentalFeatures: [.typedThrows]
)
assertParse(
Copy link
Member

Choose a reason for hiding this comment

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

Could you add empty lines between the assertParse statements to make it a little easier to read?

experimentalFeatures: .typedThrows
)
assertParse(
"[() try(MyError) async -> Void]()",
Copy link
Member

Choose a reason for hiding this comment

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

This should not parse. This seems to be an existing issue in the new parser. I filed #2255

@ahoppen
Copy link
Member

ahoppen commented Oct 2, 2023

I’ve got a couple more comments in-line. On a higher level, the following still needs to be done.

  • The following should be SPI
    • The ThrownTypeClauseSyntax type
    • SyntaxAnyVisitor.visit(ThrownTypeClauseSyntax)
    • SyntaxAnyVisitor.visitPost(ThrownTypeClauseSyntax)
    • SyntaxEnum.thrownTypeClause
    • SyntaxKind.thrownTypeClause
    • SyntaxRewriter.visit(ThrownTypeClauseSyntax)
    • SyntaxVisitor.visit(ThrownTypeClauseSyntax)
    • SyntaxVisitor.visitPost(ThrownTypeClauseSyntax)
    • The initializers of AccessorEffectSpecifiersSyntax etc. that take ThrownTypeClauseSyntax
    • unexpectedBetweenThrowsSpecifierAndThrownError and unexpectedAfterThrownError
  • Provide unexpectedAfterThrowsSpecifier that concatenates unexpectedBetweenThrowsSpecifierAndThrownError, thrownError, and unexpectedAfterThrownError
  • Ensure that all the expression tests like [() try(MyError) async -> Void]() don’t parse once #2255 is fixed

@DougGregor Could you address my review comments in a follow-up PR?

@ahoppen
Copy link
Member

ahoppen commented Oct 4, 2023

We just discussed the design of the syntax tree and I think the tree should be structured as follows:

  • The effect specifier nodes contain a throwsClasue: ThrowsClauseSyntax child, instead of throwsSpecifier and thrownType
  • The ThrowsClauseSyntax has 4 children
    • throwsSpecifier: TokenSyntax (which can be either throws or rethrows)
    • leftParent: TokenSyntax?
    • thrownType: TypeSyntax?
    • rightParen: TokenSyntax?

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