@@ -388,7 +388,7 @@ object Scanners {
388388 case InString (multiLine, _) if lastToken != STRINGPART => fetchStringPart(multiLine)
389389 case InDedentedString (quoteCount, _) if lastToken != STRINGPART =>
390390 offset = charOffset - 1
391- getDedentedStringPartWithDelimiter(quoteCount)
391+ getDedentedStringPartWithDelimiter(quoteCount, isInterpolated = true )
392392 case _ => fetchToken()
393393 if token == ERROR then adjustSepRegions(STRINGLIT ) // make sure we exit enclosing string literal
394394 else
@@ -1323,145 +1323,29 @@ object Scanners {
13231323 if (ch == CR ) nextRawChar()
13241324 if (ch == LF ) nextRawChar()
13251325
1326- // For interpolated strings, check if we need to handle $ interpolation first
1327- if (isInterpolated) {
1328- getDedentedStringPartWithDelimiter(quoteCount)
1329- quoteCount
1330- } else {
1331- // Collect all lines until we find the closing delimiter
1332- val lines = scala.collection.mutable.ArrayBuffer [String ]()
1333- val lineIndents = scala.collection.mutable.ArrayBuffer [String ]()
1334- var currentLine = new StringBuilder
1335- var currentIndent = new StringBuilder
1336- var atLineStart = true
1337- var closingIndent : String = null
1338- var foundClosing = false
1339-
1340- while (! foundClosing && ch != SU ) {
1341- if (atLineStart) {
1342- // Collect indentation
1343- currentIndent.clear()
1344- while (ch == ' ' || ch == '\t ' ) {
1345- currentIndent.append(ch)
1346- nextRawChar()
1347- }
1348-
1349- // Check if this might be the closing delimiter
1350- if (ch == '\' ' ) {
1351- var endQuoteCount = 0
1352- while (ch == '\' ' && endQuoteCount < quoteCount + 1 ) {
1353- endQuoteCount += 1
1354- nextRawChar()
1355- }
1356-
1357- if (endQuoteCount == quoteCount && ch != '\' ' ) {
1358- // Found closing delimiter (not followed by another quote)
1359- foundClosing = true
1360- closingIndent = currentIndent.toString
1361- } else {
1362- // False alarm, these quotes are part of the content
1363- // We need to restore and add them to current line
1364- currentLine.append(currentIndent)
1365- for (_ <- 0 until endQuoteCount) currentLine.append('\' ' )
1366- atLineStart = false
1367- }
1368- } else {
1369- atLineStart = false
1370- }
1371- }
1372-
1373- if (! foundClosing && ! atLineStart) {
1374- // Regular content
1375- if (ch == CR || ch == LF ) {
1376- // End of line
1377- lineIndents += currentIndent.toString
1378- lines += currentLine.toString
1379- currentLine.clear()
1380- currentIndent.clear()
1381-
1382- // Normalize newlines to \n
1383- if (ch == CR ) nextRawChar()
1384- if (ch == LF ) nextRawChar()
1385- atLineStart = true
1386- } else {
1387- currentLine.append(ch)
1388- nextRawChar()
1389- }
1390- }
1391- }
1392-
1393- if (! foundClosing) {
1394- incompleteInputError(em " unclosed dedented string literal " )
1395- } else if (closingIndent == null ) {
1396- error(em " internal error: closing indent not set " )
1397- token = ERROR
1398- } else {
1399- // Validate and dedent all lines
1400- val dedentedLines = scala.collection.mutable.ArrayBuffer [String ]()
1401- val closingIndentLen = closingIndent.length
1402- var hasSpaces = false
1403- var hasTabs = false
1404-
1405- for (indent <- closingIndent) {
1406- if (indent == ' ' ) hasSpaces = true
1407- if (indent == '\t ' ) hasTabs = true
1408- }
1326+ // Collect all content using the string part parser
1327+ getDedentedStringPartWithDelimiter(quoteCount, isInterpolated)
14091328
1410- var hasError = false
1411- for (i <- 0 until lines.length if ! hasError) {
1412- val line = lines(i)
1413- val indent = lineIndents(i)
1414-
1415- // Check for mixed tabs and spaces
1416- var lineHasSpaces = false
1417- var lineHasTabs = false
1418- for (ch <- indent) {
1419- if (ch == ' ' ) lineHasSpaces = true
1420- if (ch == '\t ' ) lineHasTabs = true
1421- }
1422-
1423- if ((hasSpaces && lineHasTabs) || (hasTabs && lineHasSpaces)) {
1424- error(em " dedented string literal cannot mix tabs and spaces in indentation " )
1425- token = ERROR
1426- hasError = true
1427- } else if (line.isEmpty) {
1428- // Empty lines are allowed
1429- dedentedLines += " "
1430- } else {
1431- // Non-empty lines must be indented at least as much as closing delimiter
1432- if (! indent.startsWith(closingIndent)) {
1433- error(em " line in dedented string literal must be indented at least as much as the closing delimiter " )
1434- token = ERROR
1435- hasError = true
1436- } else {
1437- // Remove the closing indentation from this line
1438- dedentedLines += indent.substring(closingIndentLen) + line
1439- }
1440- }
1441- }
1442-
1443- if (! hasError) {
1444- // Set the string value (join with \n)
1445- strVal = dedentedLines.mkString(" \n " )
1446- litBuf.clear()
1447- token = STRINGLIT
1448- }
1449- }
1450-
1451- quoteCount
1329+ // For non-interpolated strings, we need to dedent the collected content
1330+ if (! isInterpolated && token == STRINGLIT ) {
1331+ dedentCollectedString()
14521332 }
1333+
1334+ quoteCount
14531335 }
14541336
1455- /** Parse interpolated dedented string content, handling $ expressions .
1456- * This collects content until hitting $ or closing delimiter.
1337+ /** Parse dedented string content, with optional $ interpolation handling .
1338+ * This collects content until hitting $ (if interpolated) or closing delimiter.
14571339 * Respects the quote count for extended delimiters.
14581340 *
1459- * Note: This parses with the same format requirements as non-interpolated dedented strings
1460- * (newline after opening, extended delimiters, etc.) but does NOT dedent the content during
1461- * parsing. Dedenting for interpolated strings must be handled at runtime after all parts
1462- * are assembled, similar to how the string interpolator combines the parts.
1341+ * @param quoteCount The number of quotes in the delimiter (3 for ''', 4 for '''', etc.)
1342+ * @param isInterpolated If true, handles $ expressions and returns STRINGPART tokens.
1343+ * If false, treats $ as regular content and returns STRINGLIT.
1344+ *
1345+ * Note: Interpolated strings do NOT dedent during parsing - dedenting must be handled
1346+ * at runtime after all parts are assembled. Non-interpolated strings dedent after collection.
14631347 */
1464- @ tailrec private def getDedentedStringPartWithDelimiter (quoteCount : Int ): Unit =
1348+ @ tailrec private def getDedentedStringPartWithDelimiter (quoteCount : Int , isInterpolated : Boolean ): Unit =
14651349 // Check for closing delimiter with correct quote count
14661350 if (ch == '\' ' ) {
14671351 // Count the quotes we encounter
@@ -1479,10 +1363,10 @@ object Scanners {
14791363 } else {
14801364 // Not the closing delimiter, add the quotes we found to content
14811365 for (_ <- 0 until foundQuotes) putChar('\' ' )
1482- getDedentedStringPartWithDelimiter(quoteCount)
1366+ getDedentedStringPartWithDelimiter(quoteCount, isInterpolated )
14831367 }
14841368 }
1485- else if (ch == '$' ) {
1369+ else if (isInterpolated && ch == '$' ) {
14861370 // Handle interpolation
14871371 def getInterpolatedIdentRest (hasSupplement : Boolean ): Unit =
14881372 @ tailrec def loopRest (): Unit =
@@ -1510,7 +1394,7 @@ object Scanners {
15101394 if (ch == '$' || ch == '\' ' ) {
15111395 putChar(ch)
15121396 nextRawChar()
1513- getDedentedStringPartWithDelimiter(quoteCount)
1397+ getDedentedStringPartWithDelimiter(quoteCount, isInterpolated )
15141398 }
15151399 else if (ch == '{' ) {
15161400 setStrVal()
@@ -1523,7 +1407,7 @@ object Scanners {
15231407 else
15241408 error(" invalid string interpolation: `$$`, `$'`, `$`ident or `$`BlockExpr expected" .toMessage, off = charOffset - 2 )
15251409 putChar('$' )
1526- getDedentedStringPartWithDelimiter(quoteCount)
1410+ getDedentedStringPartWithDelimiter(quoteCount, isInterpolated )
15271411 }
15281412 else {
15291413 val isUnclosedLiteral = ! isUnicodeEscape && ch == SU
@@ -1532,11 +1416,106 @@ object Scanners {
15321416 else {
15331417 putChar(ch)
15341418 nextRawChar()
1535- getDedentedStringPartWithDelimiter(quoteCount)
1419+ getDedentedStringPartWithDelimiter(quoteCount, isInterpolated )
15361420 }
15371421 }
15381422 end getDedentedStringPartWithDelimiter
15391423
1424+ /** Dedent a collected string by analyzing line structure and removing common indentation.
1425+ * This processes the content in `strVal`, validating indentation rules and removing
1426+ * the minimum common indentation from all non-empty lines.
1427+ */
1428+ private def dedentCollectedString (): Unit = {
1429+ val content = strVal
1430+ if (content.isEmpty) return
1431+
1432+ val lines = scala.collection.mutable.ArrayBuffer [String ]()
1433+ val lineIndents = scala.collection.mutable.ArrayBuffer [String ]()
1434+
1435+ // Parse content into lines with their indentation
1436+ var i = 0
1437+ while (i < content.length) {
1438+ // Collect indentation for this line
1439+ val indentStart = i
1440+ while (i < content.length && (content(i) == ' ' || content(i) == '\t ' )) {
1441+ i += 1
1442+ }
1443+ val indent = content.substring(indentStart, i)
1444+
1445+ // Collect rest of line
1446+ val lineStart = i
1447+ while (i < content.length && content(i) != '\n ' ) {
1448+ i += 1
1449+ }
1450+ val line = content.substring(lineStart, i)
1451+
1452+ lines += line
1453+ lineIndents += indent
1454+
1455+ // Skip the newline
1456+ if (i < content.length && content(i) == '\n ' ) {
1457+ i += 1
1458+ }
1459+ }
1460+
1461+ // The last line's indentation is the closing indentation
1462+ if (lines.isEmpty) {
1463+ strVal = " "
1464+ return
1465+ }
1466+
1467+ val closingIndent = lineIndents.last
1468+ val closingIndentLen = closingIndent.length
1469+
1470+ // Check for mixed tabs/spaces in closing indent
1471+ var hasSpaces = false
1472+ var hasTabs = false
1473+ for (ch <- closingIndent) {
1474+ if (ch == ' ' ) hasSpaces = true
1475+ if (ch == '\t ' ) hasTabs = true
1476+ }
1477+
1478+ // Validate and dedent all lines
1479+ val dedentedLines = scala.collection.mutable.ArrayBuffer [String ]()
1480+ var hasError = false
1481+
1482+ for (i <- 0 until lines.length - 1 if ! hasError) { // Skip last line (it's empty after closing delimiter)
1483+ val line = lines(i)
1484+ val indent = lineIndents(i)
1485+
1486+ // Check for mixed tabs and spaces
1487+ var lineHasSpaces = false
1488+ var lineHasTabs = false
1489+ for (ch <- indent) {
1490+ if (ch == ' ' ) lineHasSpaces = true
1491+ if (ch == '\t ' ) lineHasTabs = true
1492+ }
1493+
1494+ if ((hasSpaces && lineHasTabs) || (hasTabs && lineHasSpaces)) {
1495+ error(em " dedented string literal cannot mix tabs and spaces in indentation " )
1496+ token = ERROR
1497+ hasError = true
1498+ } else if (line.isEmpty) {
1499+ // Empty lines are allowed
1500+ dedentedLines += " "
1501+ } else {
1502+ // Non-empty lines must be indented at least as much as closing delimiter
1503+ if (! indent.startsWith(closingIndent)) {
1504+ error(em " line in dedented string literal must be indented at least as much as the closing delimiter " )
1505+ token = ERROR
1506+ hasError = true
1507+ } else {
1508+ // Remove the closing indentation from this line
1509+ dedentedLines += indent.substring(closingIndentLen) + line
1510+ }
1511+ }
1512+ }
1513+
1514+ if (! hasError) {
1515+ strVal = dedentedLines.mkString(" \n " )
1516+ }
1517+ }
1518+
15401519 private def getRawStringLit (): Unit =
15411520 if (ch == '\" ' ) {
15421521 nextRawChar()
0 commit comments