Have dumbindent handle multi-line strings/comments

Fixes #31
diff --git a/cmd/dumbindent/main.go b/cmd/dumbindent/main.go
index fda4103..d01652f 100644
--- a/cmd/dumbindent/main.go
+++ b/cmd/dumbindent/main.go
@@ -44,9 +44,6 @@
 // ----
 //
 // There are no configuration options (e.g. tabs versus spaces).
-//
-// Known bug: it cannot handle /* slash-star comments */ or multi-line strings
-// yet. This is tracked at https://github.com/google/wuffs/issues/31
 package main
 
 import (
@@ -145,10 +142,7 @@
 		return err
 	}
 
-	dst, err := dumbindent.FormatBytes(nil, src)
-	if err != nil {
-		return err
-	}
+	dst := dumbindent.FormatBytes(nil, src)
 
 	if r != nil {
 		if _, err := os.Stdout.Write(dst); err != nil {
diff --git a/internal/cgen/cgen.go b/internal/cgen/cgen.go
index 1abce1e..2db9c38 100644
--- a/internal/cgen/cgen.go
+++ b/internal/cgen/cgen.go
@@ -182,7 +182,7 @@
 			return unformatted, nil
 		}
 
-		return dumbindent.FormatBytes(nil, unformatted)
+		return dumbindent.FormatBytes(nil, unformatted), nil
 	})
 }
 
diff --git a/lib/dumbindent/dumbindent.go b/lib/dumbindent/dumbindent.go
index fbdd1f9..ad0c811 100644
--- a/lib/dumbindent/dumbindent.go
+++ b/lib/dumbindent/dumbindent.go
@@ -27,20 +27,18 @@
 // `dumbindent` was 80 times faster than `clang-format`.
 //
 // There are no configuration options (e.g. tabs versus spaces).
-//
-// Known bug: it cannot handle /* slash-star comments */ or multi-line strings
-// yet. This is tracked at https://github.com/google/wuffs/issues/31
 package dumbindent
 
 import (
 	"bytes"
-	"errors"
 )
 
 // 'Constants', but their type is []byte, not string.
 var (
-	externC = []byte("extern \"C\"")
-	spaces  = []byte("                                ")
+	backTick  = []byte("`")
+	externC   = []byte("extern \"C\"")
+	spaces    = []byte("                                ")
+	starSlash = []byte("*/")
 )
 
 // hangingBytes is a look-up table for updating the hanging variable.
@@ -55,9 +53,9 @@
 // It is valid to pass a dst slice (such as nil) whose spare capacity (not
 // including its existing elements) is too short to hold the formatted program.
 // In this case, a new slice will be allocated and returned.
-func FormatBytes(dst []byte, src []byte) ([]byte, error) {
+func FormatBytes(dst []byte, src []byte) []byte {
 	if len(src) == 0 {
-		return dst, nil
+		return dst
 	} else if len(dst) == 0 {
 		dst = make([]byte, 0, len(src)+(len(src)/2))
 	}
@@ -69,11 +67,12 @@
 	blankLine := false // Whether the previous line was blank.
 
 	for line, remaining := src, []byte(nil); len(src) > 0; src = remaining {
+		src = trimLeadingWhiteSpace(src)
 		line, remaining = src, nil
 		if i := bytes.IndexByte(line, '\n'); i >= 0 {
 			line, remaining = line[:i], line[i+1:]
 		}
-		line = trimSpace(line)
+		lineLength := len(line)
 
 		// Collapse 2 or more consecutive blank lines into 1. Also strip any
 		// blank lines:
@@ -99,6 +98,7 @@
 		if (line[0] == '#') ||
 			((line[0] == 'e') && bytes.HasPrefix(line, externC)) ||
 			((line[0] == '}') && bytes.HasSuffix(line, externC)) {
+			line = trimTrailingWhiteSpace(line)
 			dst = append(dst, line...)
 			dst = append(dst, '\n')
 			openBrace = false
@@ -150,16 +150,16 @@
 			indent -= n
 		}
 
-		// Output the line itself.
-		dst = append(dst, line...)
-		dst = append(dst, "\n"...)
+		// Output the leading '}'s.
+		dst = append(dst, line[:closeBraces]...)
+		line = line[closeBraces:]
 
 		// Adjust the state according to the braces and parentheses within the
 		// line (except for those in comments and strings).
 		last := lastNonWhiteSpace(line)
 	loop:
-		for s := line[closeBraces:]; ; {
-			for i, c := range s {
+		for {
+			for i, c := range line {
 				switch c {
 				case '{':
 					nBraces++
@@ -169,23 +169,39 @@
 					nParens++
 				case ')':
 					nParens--
+
 				case '/':
-					if (i + 1) >= len(s) {
+					if (i + 1) >= len(line) {
 						break
 					}
-					if s[i+1] == '/' {
+					if line[i+1] == '/' {
 						// A slash-slash comment. Skip the rest of the line.
-						last = lastNonWhiteSpace(s[:i])
+						last = lastNonWhiteSpace(line[:i])
 						break loop
-					} else if s[i+1] == '*' {
-						return nil, errors.New("dumbindent: TODO: support slash-star comments")
+					} else if line[i+1] == '*' {
+						// A slash-star comment.
+						dst = append(dst, line[:i+2]...)
+						restOfLine := line[i+2:]
+						restOfSrc := src[lineLength-len(restOfLine):]
+						dst, line, remaining = handleRaw(dst, restOfSrc, starSlash)
+						last = lastNonWhiteSpace(line)
+						continue loop
 					}
+
 				case '"', '\'':
-					if suffix, err := skipString(s[i+1:], c); err != nil {
-						return nil, err
-					} else {
-						s = suffix
-					}
+					// A cooked string, whose contents are backslash-escaped.
+					suffix := skipCooked(line[i+1:], c)
+					dst = append(dst, line[:len(line)-len(suffix)]...)
+					line = suffix
+					continue loop
+
+				case '`':
+					// A raw string.
+					dst = append(dst, line[:i+1]...)
+					restOfLine := line[i+1:]
+					restOfSrc := src[lineLength-len(restOfLine):]
+					dst, line, remaining = handleRaw(dst, restOfSrc, backTick)
+					last = lastNonWhiteSpace(line)
 					continue loop
 				}
 			}
@@ -193,15 +209,25 @@
 		}
 		openBrace = last == '{'
 		hanging = hangingBytes[last]
+
+		// Output the line (minus any trailing space).
+		line = trimTrailingWhiteSpace(line)
+		dst = append(dst, line...)
+		dst = append(dst, "\n"...)
 	}
-	return dst, nil
+	return dst
 }
 
-// trimSpace converts "\t  foo bar " to "foo bar".
-func trimSpace(s []byte) []byte {
+// trimLeadingWhiteSpace converts "\t  foo bar " to "foo bar ".
+func trimLeadingWhiteSpace(s []byte) []byte {
 	for (len(s) > 0) && ((s[0] == ' ') || (s[0] == '\t')) {
 		s = s[1:]
 	}
+	return s
+}
+
+// trimTrailingWhiteSpace converts "\t  foo bar " to "\t  foo bar".
+func trimTrailingWhiteSpace(s []byte) []byte {
 	for (len(s) > 0) && ((s[len(s)-1] == ' ') || (s[len(s)-1] == '\t')) {
 		s = s[:len(s)-1]
 	}
@@ -241,11 +267,11 @@
 	return 0
 }
 
-// skipString converts `ijk \" lmn" pqr` to ` pqr`.
-func skipString(s []byte, quote byte) (suffix []byte, retErr error) {
+// skipCooked converts `ijk \" lmn" pqr` to ` pqr`.
+func skipCooked(s []byte, quote byte) (suffix []byte) {
 	for i := 0; i < len(s); {
 		if x := s[i]; x == quote {
-			return s[i+1:], nil
+			return s[i+1:]
 		} else if x != '\\' {
 			i += 1
 		} else if (i + 1) < len(s) {
@@ -254,5 +280,22 @@
 			break
 		}
 	}
-	return nil, errors.New("dumbindent: TODO: support multi-line strings")
+	return nil
+}
+
+// handleRaw copies a raw string from restOfSrc to dst, re-calculating the
+// (line, remaining) pair afterwards.
+func handleRaw(dst []byte, restOfSrc []byte, endQuote []byte) (retDst []byte, line []byte, remaining []byte) {
+	end := bytes.Index(restOfSrc, endQuote)
+	if end < 0 {
+		end = len(restOfSrc)
+	} else {
+		end += len(endQuote)
+	}
+	dst = append(dst, restOfSrc[:end]...)
+	line, remaining = restOfSrc[end:], nil
+	if i := bytes.IndexByte(line, '\n'); i >= 0 {
+		line, remaining = line[:i], line[i+1:]
+	}
+	return dst, line, remaining
 }
diff --git a/lib/dumbindent/dumbindent_test.go b/lib/dumbindent/dumbindent_test.go
index d788602..3b3bef7 100644
--- a/lib/dumbindent/dumbindent_test.go
+++ b/lib/dumbindent/dumbindent_test.go
@@ -23,6 +23,10 @@
 		src  string
 		want string
 	}{{
+		// Leading and trailing space.
+		src:  "\t\tx y  \n",
+		want: "x y\n",
+	}, {
 		// Braces.
 		src:  "foo{\nbar\n    }\nbaz\n",
 		want: "foo{\n  bar\n}\nbaz\n",
@@ -51,15 +55,21 @@
 		src:  "a = \"{\"\nb = {\nc = 0\n",
 		want: "a = \"{\"\nb = {\n  c = 0\n",
 	}, {
+		// Back-tick string.
+		src:  "a['key'] = `{\n\n\nX` ; \nb = {\nc = 0\n",
+		want: "a['key'] = `{\n\n\nX` ;\nb = {\n  c = 0\n",
+	}, {
+		// Slash-star comment.
+		src:  "   a['key'] = /*{\n\n\nX*/ ; \nb = {\nc = 0\n",
+		want: "a['key'] = /*{\n\n\nX*/ ;\nb = {\n  c = 0\n",
+	}, {
 		// Label.
 		src:  "if (b) {\nlabel:\nswitch (i) {\ncase 0:\nj = k\nbreak;\n}\n}\n",
 		want: "if (b) {\nlabel:\n  switch (i) {\n    case 0:\n    j = k\n    break;\n  }\n}\n",
 	}}
 
 	for i, tc := range testCases {
-		if g, err := FormatBytes(nil, []byte(tc.src)); err != nil {
-			tt.Fatalf("i=%d, src=%q: %v", i, tc.src, err)
-		} else if got := string(g); got != tc.want {
+		if got := string(FormatBytes(nil, []byte(tc.src))); got != tc.want {
 			tt.Fatalf("i=%d, src=%q:\ngot  %q\nwant %q", i, tc.src, got, tc.want)
 		}
 	}