@@ -978,7 +978,19 @@ object Scanners {
978978 case '\' ' =>
979979 def fetchSingleQuote (): Unit = {
980980 nextChar()
981- if isIdentifierStart(ch) then
981+ // Check for triple single quote (dedented string literal)
982+ if (ch == '\' ' ) {
983+ nextChar()
984+ if (ch == '\' ' ) {
985+ // We have at least '''
986+ getDedentedStringLit()
987+ }
988+ else {
989+ // We have '' followed by something else
990+ error(em " empty character literal " )
991+ }
992+ }
993+ else if isIdentifierStart(ch) then
982994 charLitOr { getIdentRest(); QUOTEID }
983995 else if isOperatorPart(ch) && ch != '\\ ' then
984996 charLitOr { getOperatorRest(); QUOTEID }
@@ -1255,6 +1267,160 @@ object Scanners {
12551267 else error(em " unclosed string literal " )
12561268 }
12571269
1270+ /** Parse a dedented string literal (triple single quotes)
1271+ * Requirements:
1272+ * - Must start with ''' followed by newline
1273+ * - Must end with newline + whitespace + '''
1274+ * - Removes first newline after opening delimiter
1275+ * - Removes final newline and preceding whitespace before closing delimiter
1276+ * - Strips indentation equal to closing delimiter indentation
1277+ * - All lines must be empty or indented further than closing delimiter
1278+ * - Supports extended delimiters (e.g., '''', ''''')
1279+ */
1280+ private def getDedentedStringLit (): Unit = {
1281+ // Count opening quotes (already consumed 3)
1282+ nextChar()
1283+ var quoteCount = 3
1284+ while (ch == '\' ' ) {
1285+ quoteCount += 1
1286+ nextChar()
1287+ }
1288+
1289+ // Must be followed by a newline
1290+ if (ch != LF && ch != CR ) {
1291+ error(em " dedented string literal must start with newline after opening quotes " )
1292+ token = ERROR
1293+ } else {
1294+ // Skip the initial newline (CR LF or just LF)
1295+ if (ch == CR ) nextRawChar()
1296+ if (ch == LF ) nextRawChar()
1297+
1298+ // Collect all lines until we find the closing delimiter
1299+ val lines = scala.collection.mutable.ArrayBuffer [String ]()
1300+ val lineIndents = scala.collection.mutable.ArrayBuffer [String ]()
1301+ var currentLine = new StringBuilder
1302+ var currentIndent = new StringBuilder
1303+ var atLineStart = true
1304+ var closingIndent : String = null
1305+ var foundClosing = false
1306+
1307+ while (! foundClosing && ch != SU ) {
1308+ if (atLineStart) {
1309+ // Collect indentation
1310+ currentIndent.clear()
1311+ while (ch == ' ' || ch == '\t ' ) {
1312+ currentIndent.append(ch)
1313+ nextRawChar()
1314+ }
1315+
1316+ // Check if this might be the closing delimiter
1317+ if (ch == '\' ' ) {
1318+ var endQuoteCount = 0
1319+ val savedOffset = charOffset
1320+ while (ch == '\' ' && endQuoteCount < quoteCount + 1 ) {
1321+ endQuoteCount += 1
1322+ nextRawChar()
1323+ }
1324+
1325+ if (endQuoteCount == quoteCount && (ch == SU || ch == CR || ch == LF || ch == ' ' || ch == '\t ' || ch == ';' )) {
1326+ // Found closing delimiter
1327+ foundClosing = true
1328+ closingIndent = currentIndent.toString
1329+ // Consume any trailing whitespace/newlines after closing quotes
1330+ while (ch == ' ' || ch == '\t ' ) nextChar()
1331+ if (ch == CR || ch == LF ) nextChar()
1332+ } else {
1333+ // False alarm, these quotes are part of the content
1334+ // We need to restore and add them to current line
1335+ currentLine.append(currentIndent)
1336+ for (_ <- 0 until endQuoteCount) currentLine.append('\' ' )
1337+ atLineStart = false
1338+ }
1339+ } else {
1340+ atLineStart = false
1341+ }
1342+ }
1343+
1344+ if (! foundClosing && ! atLineStart) {
1345+ // Regular content
1346+ if (ch == CR || ch == LF ) {
1347+ // End of line
1348+ lineIndents += currentIndent.toString
1349+ lines += currentLine.toString
1350+ currentLine.clear()
1351+ currentIndent.clear()
1352+
1353+ // Normalize newlines to \n
1354+ if (ch == CR ) nextRawChar()
1355+ if (ch == LF ) nextRawChar()
1356+ atLineStart = true
1357+ } else {
1358+ currentLine.append(ch)
1359+ nextRawChar()
1360+ }
1361+ }
1362+ }
1363+
1364+ if (! foundClosing) {
1365+ incompleteInputError(em " unclosed dedented string literal " )
1366+ } else if (closingIndent == null ) {
1367+ error(em " internal error: closing indent not set " )
1368+ token = ERROR
1369+ } else {
1370+ // Validate and dedent all lines
1371+ val dedentedLines = scala.collection.mutable.ArrayBuffer [String ]()
1372+ val closingIndentLen = closingIndent.length
1373+ var hasSpaces = false
1374+ var hasTabs = false
1375+
1376+ for (indent <- closingIndent) {
1377+ if (indent == ' ' ) hasSpaces = true
1378+ if (indent == '\t ' ) hasTabs = true
1379+ }
1380+
1381+ var hasError = false
1382+ for (i <- 0 until lines.length if ! hasError) {
1383+ val line = lines(i)
1384+ val indent = lineIndents(i)
1385+
1386+ // Check for mixed tabs and spaces
1387+ var lineHasSpaces = false
1388+ var lineHasTabs = false
1389+ for (ch <- indent) {
1390+ if (ch == ' ' ) lineHasSpaces = true
1391+ if (ch == '\t ' ) lineHasTabs = true
1392+ }
1393+
1394+ if ((hasSpaces && lineHasTabs) || (hasTabs && lineHasSpaces)) {
1395+ error(em " dedented string literal cannot mix tabs and spaces in indentation " )
1396+ token = ERROR
1397+ hasError = true
1398+ } else if (line.isEmpty) {
1399+ // Empty lines are allowed
1400+ dedentedLines += " "
1401+ } else {
1402+ // Non-empty lines must be indented at least as much as closing delimiter
1403+ if (! indent.startsWith(closingIndent)) {
1404+ error(em " line in dedented string literal must be indented at least as much as the closing delimiter " )
1405+ token = ERROR
1406+ hasError = true
1407+ } else {
1408+ // Remove the closing indentation from this line
1409+ dedentedLines += indent.substring(closingIndentLen) + line
1410+ }
1411+ }
1412+ }
1413+
1414+ if (! hasError) {
1415+ // Set the string value (join with \n)
1416+ strVal = dedentedLines.mkString(" \n " )
1417+ litBuf.clear()
1418+ token = STRINGLIT
1419+ }
1420+ }
1421+ }
1422+ }
1423+
12581424 private def getRawStringLit (): Unit =
12591425 if (ch == '\" ' ) {
12601426 nextRawChar()
0 commit comments