Repository: swiftlang/swift-syntax (swift)

Issue:
Parser enters infinite loop in string parsing
### Issue Kind

Parser Crash

### Source Code

```swift
"\("\(
```


### Description

_No response_

Pull request:
If we have two nested, unterminated string interpolation segments, the lexer generates two empty `stringLiteral` tokens (one after each interpolation segment). When consuming the first empty string segment, we did actually make progress in the lexer by popping one nested string interpolation off the state stack. However, `LoopProgressCondition` did not consider this progress because it only looked at the top state in the state stack.

To fix this, consider the state stack size in `LoopProgressCondition` as well.

Fixes #2533
rdar://124168557

Patch:
diff --git a/Sources/SwiftParser/Lexer/Cursor.swift b/Sources/SwiftParser/Lexer/Cursor.swift
index b9fc4e507a2..04f0722ec24 100644
--- a/Sources/SwiftParser/Lexer/Cursor.swift
+++ b/Sources/SwiftParser/Lexer/Cursor.swift
@@ -51,7 +51,7 @@ extension Lexer.Cursor {
   ///  - A string interpolation inside is entered
   ///  - A regex literal is being lexed
   ///  - A narrow case for 'try?' and 'try!' to ensure correct regex lexing
-  enum State {
+  enum State: Equatable {
     /// Normal top-level lexing mode
     case normal
 
@@ -202,6 +202,11 @@ extension Lexer.Cursor {
         }
       }
     }
+
+    /// See `Lexer.Cursor.hasProgressed(comparedTo:)`.
+    fileprivate func hasProgressed(comparedTo other: StateStack) -> Bool {
+      return currentState != other.currentState || stateStack?.count != other.stateStack?.count
+    }
   }
 
   /// An error that was discovered in a lexeme while lexing it.
@@ -256,6 +261,16 @@ extension Lexer {
       self.position = Position(input: input, previous: previous)
     }
 
+    /// Returns `true` if this cursor is sufficiently different to `other` in a way that indicates that the lexer has
+    /// made progress.
+    ///
+    /// This is the case if the lexer advanced its position in the source file or if it has performed a state
+    /// transition.
+    func hasProgressed(comparedTo other: Cursor) -> Bool {
+      return position.input.baseAddress != other.position.input.baseAddress
+        || stateStack.hasProgressed(comparedTo: other.stateStack)
+    }
+
     var input: UnsafeBufferPointer<UInt8> { position.input }
     var previous: UInt8 { position.previous }
 
diff --git a/Sources/SwiftParser/LoopProgressCondition.swift b/Sources/SwiftParser/LoopProgressCondition.swift
index 9a9b6bf6803..8c0d8334860 100644
--- a/Sources/SwiftParser/LoopProgressCondition.swift
+++ b/Sources/SwiftParser/LoopProgressCondition.swift
@@ -29,14 +29,7 @@ struct LoopProgressCondition {
     guard let previousToken = self.currentToken else {
       return true
     }
-    // The loop has made progress if either
-    //  - the parser is now pointing at a different location in the source file
-    //  - the parser is still pointing at the same position in the source file
-    //     but now has a different token kind (and thus consumed a zero-length
-    //     token like an empty string interpolation
-    let hasMadeProgress =
-      previousToken.tokenText.baseAddress != currentToken.tokenText.baseAddress
-      || (previousToken.byteLength == 0 && previousToken.rawTokenKind != currentToken.rawTokenKind)
+    let hasMadeProgress = currentToken.cursor.hasProgressed(comparedTo: previousToken.cursor)
     assert(hasMadeProgress, "Loop should always make progress")
     return hasMadeProgress
   }


Test patch:
diff --git a/Tests/SwiftParserTest/LexerTests.swift b/Tests/SwiftParserTest/LexerTests.swift
index cf632450703..cf9296fc186 100644
--- a/Tests/SwiftParserTest/LexerTests.swift
+++ b/Tests/SwiftParserTest/LexerTests.swift
@@ -1561,4 +1561,26 @@ public class LexerTests: ParserTestCase {
       ]
     )
   }
+
+  func testNestedUnterminatedStringInterpolations() {
+    assertLexemes(
+      #"""
+      "\("\(
+
+      """#,
+      lexemes: [
+        LexemeSpec(.stringQuote, text: #"""#),
+        LexemeSpec(.stringSegment, text: ""),
+        LexemeSpec(.backslash, text: #"\"#),
+        LexemeSpec(.leftParen, text: "("),
+        LexemeSpec(.stringQuote, text: #"""#),
+        LexemeSpec(.stringSegment, text: ""),
+        LexemeSpec(.backslash, text: #"\"#),
+        LexemeSpec(.leftParen, text: "("),
+        LexemeSpec(.stringSegment, text: ""),
+        LexemeSpec(.stringSegment, text: ""),
+        LexemeSpec(.endOfFile, leading: "\n", text: "", flags: [.isAtStartOfLine]),
+      ]
+    )
+  }
 }
diff --git a/Tests/SwiftParserTest/translated/UnclosedStringInterpolationTests.swift b/Tests/SwiftParserTest/translated/UnclosedStringInterpolationTests.swift
index 4be5607e46d..6aad11f0c19 100644
--- a/Tests/SwiftParserTest/translated/UnclosedStringInterpolationTests.swift
+++ b/Tests/SwiftParserTest/translated/UnclosedStringInterpolationTests.swift
@@ -247,4 +247,38 @@ final class UnclosedStringInterpolationTests: ParserTestCase {
         """#
     )
   }
+
+  func testNestedUnterminatedStringInterpolation() {
+    assertParse(
+      #"""
+      1️⃣"\2️⃣(3️⃣"\(4️⃣
+
+      """#,
+      diagnostics: [
+        DiagnosticSpec(locationMarker: "4️⃣", message: "expected value and ')' in string literal", fixIts: ["insert value and ')'"]),
+        DiagnosticSpec(
+          locationMarker: "4️⃣",
+          message: #"expected '"' to end string literal"#,
+          notes: [NoteSpec(locationMarker: "3️⃣", message: #"to match this opening '"'"#)],
+          fixIts: [#"insert '"'"#]
+        ),
+        DiagnosticSpec(
+          locationMarker: "4️⃣",
+          message: "expected ')' in string literal",
+          notes: [NoteSpec(locationMarker: "2️⃣", message: "to match this opening '('")],
+          fixIts: ["insert ')'"]
+        ),
+        DiagnosticSpec(
+          locationMarker: "4️⃣",
+          message: #"expected '"' to end string literal"#,
+          notes: [NoteSpec(locationMarker: "1️⃣", message: #"to match this opening '"'"#)],
+          fixIts: [#"insert '"'"#]
+        ),
+      ],
+      fixedSource: #"""
+        "\("\(<#expression#>)")"
+
+        """#
+    )
+  }
 }