message/pipeline: hoist export code from cmd/text

Change-Id: I51ff721870bd91ade99d7afede56556535bc93bc
Reviewed-on: https://go-review.googlesource.com/83818
Run-TryBot: Marcel van Lohuizen <mpvl@golang.org>
TryBot-Result: Gobot Gobot <gobot@golang.org>
Reviewed-by: Nigel Tao <nigeltao@golang.org>
diff --git a/cmd/gotext/extract.go b/cmd/gotext/extract.go
index 221e776..8c80670 100644
--- a/cmd/gotext/extract.go
+++ b/cmd/gotext/extract.go
@@ -5,12 +5,6 @@
 package main
 
 import (
-	"encoding/json"
-	"io/ioutil"
-	"os"
-	"path/filepath"
-
-	"golang.org/x/text/internal"
 	"golang.org/x/text/message/pipeline"
 )
 
@@ -40,24 +34,5 @@
 	if err != nil {
 		return wrap(err, "extract failed")
 	}
-	out := state.Extracted
-
-	langs := append(getLangs(), config.SourceLanguage)
-	langs = internal.UniqueTags(langs)
-	for _, tag := range langs {
-		// TODO: inject translations from existing files to avoid retranslation.
-		out.Language = tag
-		data, err := json.MarshalIndent(out, "", "    ")
-		if err != nil {
-			return wrap(err, "JSON marshal failed")
-		}
-		file := filepath.Join(*dir, tag.String(), outFile)
-		if err := os.MkdirAll(filepath.Dir(file), 0750); err != nil {
-			return wrap(err, "dir create failed")
-		}
-		if err := ioutil.WriteFile(file, data, 0740); err != nil {
-			return wrap(err, "write failed")
-		}
-	}
-	return nil
+	return wrap(state.Export(), "export failed")
 }
diff --git a/message/pipeline/pipeline.go b/message/pipeline/pipeline.go
index 8087bf6..ff4505f 100644
--- a/message/pipeline/pipeline.go
+++ b/message/pipeline/pipeline.go
@@ -15,12 +15,14 @@
 	"go/parser"
 	"io/ioutil"
 	"log"
+	"os"
 	"path/filepath"
 	"regexp"
 	"strings"
 	"text/template"
 	"unicode"
 
+	"golang.org/x/text/internal"
 	"golang.org/x/text/language"
 	"golang.org/x/text/runes"
 	"golang.org/x/tools/go/loader"
@@ -248,7 +250,31 @@
 
 // Export writes out the messages to translation out files.
 func (s *State) Export() error {
-	panic("unimplemented")
+	langs := []language.Tag{s.Config.SourceLanguage}
+	for _, t := range s.Translations {
+		langs = append(langs, t.Language)
+	}
+	langs = internal.UniqueTags(langs)
+	out := s.Extracted
+	path, err := outPattern(s)
+	if err != nil {
+		return wrap(err, "export failed")
+	}
+	for _, tag := range langs {
+		// TODO: inject translations from existing files to avoid retranslation.
+		out.Language = tag
+		data, err := json.MarshalIndent(out, "", "    ")
+		if err != nil {
+			return wrap(err, "JSON marshal failed")
+		}
+		file := fmt.Sprintf(path, tag)
+		if err := os.MkdirAll(filepath.Dir(file), 0755); err != nil {
+			return wrap(err, "dir create failed")
+		}
+		if err := ioutil.WriteFile(file, data, 0644); err != nil {
+			return wrap(err, "write failed")
+		}
+	}
 	return nil
 }
 
diff --git a/message/pipeline/pipeline_test.go b/message/pipeline/pipeline_test.go
index 198d47f..78815f1 100644
--- a/message/pipeline/pipeline_test.go
+++ b/message/pipeline/pipeline_test.go
@@ -57,7 +57,7 @@
 			//  for range s.Config.Actions {
 			//  	//  TODO: do the actions.
 			//  }
-			// chk(t, s.Export()) // TODO
+			chk(t, s.Export())
 			chk(t, s.Generate())
 
 			writeJSON(t, filepath.Join(dir, "extracted.gotext.json"), s.Extracted)
@@ -93,14 +93,10 @@
 			scanWant := bufio.NewScanner(bytes.NewReader(want))
 			line := 0
 			clean := func(s string) string {
-				s = path.Clean(filepath.ToSlash(s))
-				if i := strings.LastIndex(s, "Size:"); i != -1 {
+				if i := strings.LastIndex(s, "//"); i != -1 {
 					s = s[:i]
 				}
-				if i := strings.LastIndex(s, "Total table size"); i != -1 {
-					s = s[:i]
-				}
-				return s
+				return path.Clean(filepath.ToSlash(s))
 			}
 			for scanGot.Scan() && scanWant.Scan() {
 				got := clean(scanGot.Text())
diff --git a/message/pipeline/testdata/test1/catalog_test.go b/message/pipeline/testdata/test1/catalog_test.go
new file mode 100644
index 0000000..eeb7c25
--- /dev/null
+++ b/message/pipeline/testdata/test1/catalog_test.go
@@ -0,0 +1,49 @@
+// Copyright 2017 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package main
+
+import (
+	"path"
+	"testing"
+
+	"golang.org/x/text/message"
+)
+
+func TestCatalog(t *testing.T) {
+	args := func(a ...interface{}) []interface{} { return a }
+	testCases := []struct {
+		lang string
+		key  string
+		args []interface{}
+		want string
+	}{{
+		lang: "en",
+		key:  "Hello world!\n",
+		want: "Hello world!\n",
+	}, {
+		lang: "de",
+		key:  "Hello world!\n",
+		want: "Hallo Welt!\n",
+	}, {
+		lang: "en",
+		key:  "%d more files remaining!",
+		args: args(1),
+		want: "One file remaining!",
+	}, {
+		lang: "en-u-nu-fullwide",
+		key:  "%d more files remaining!",
+		args: args(5),
+		want: "There are 5 more files remaining!",
+	}}
+	for _, tc := range testCases {
+		t.Run(path.Join(tc.lang, tc.key), func(t *testing.T) {
+			p := message.NewPrinter(message.MatchLanguage(tc.lang))
+			got := p.Sprintf(tc.key, tc.args...)
+			if got != tc.want {
+				t.Errorf("got %q; want %q", got, tc.want)
+			}
+		})
+	}
+}
diff --git a/message/pipeline/testdata/test1/locales/de/out.gotext.json b/message/pipeline/testdata/test1/locales/de/out.gotext.json
new file mode 100755
index 0000000..9c7e594
--- /dev/null
+++ b/message/pipeline/testdata/test1/locales/de/out.gotext.json
@@ -0,0 +1,188 @@
+{
+    "language": "de",
+    "messages": [
+        {
+            "id": "Hello world!",
+            "key": "Hello world!\n",
+            "message": "Hello world!",
+            "translation": "",
+            "position": "testdata/test1/test1.go:19:10"
+        },
+        {
+            "id": "Hello {City}!",
+            "key": "Hello %s!\n",
+            "message": "Hello {City}!",
+            "translation": "",
+            "placeholders": [
+                {
+                    "id": "City",
+                    "string": "%[1]s",
+                    "type": "string",
+                    "underlyingType": "string",
+                    "argNum": 1,
+                    "expr": "city"
+                }
+            ],
+            "position": "testdata/test1/test1.go:24:10"
+        },
+        {
+            "id": "{Person} is visiting {Place}!",
+            "key": "%s is visiting %s!\n",
+            "message": "{Person} is visiting {Place}!",
+            "translation": "",
+            "placeholders": [
+                {
+                    "id": "Person",
+                    "string": "%[1]s",
+                    "type": "string",
+                    "underlyingType": "string",
+                    "argNum": 1,
+                    "expr": "person",
+                    "comment": "The person of matter."
+                },
+                {
+                    "id": "Place",
+                    "string": "%[2]s",
+                    "type": "string",
+                    "underlyingType": "string",
+                    "argNum": 2,
+                    "expr": "place",
+                    "comment": "Place the person is visiting."
+                }
+            ],
+            "position": "testdata/test1/test1.go:30:10"
+        },
+        {
+            "id": "{Person} is visiting {Place}!",
+            "key": "%[1]s is visiting %[3]s!\n",
+            "message": "{Person} is visiting {Place}!",
+            "translation": "",
+            "comment": "Field names are placeholders.",
+            "placeholders": [
+                {
+                    "id": "Person",
+                    "string": "%[1]s",
+                    "type": "string",
+                    "underlyingType": "string",
+                    "argNum": 1,
+                    "expr": "pp.Person"
+                },
+                {
+                    "id": "Place",
+                    "string": "%[3]s",
+                    "type": "string",
+                    "underlyingType": "string",
+                    "argNum": 3,
+                    "expr": "pp.Place",
+                    "comment": "Place the person is visiting."
+                },
+                {
+                    "id": "Extra",
+                    "string": "%[2]v",
+                    "type": "int",
+                    "underlyingType": "int",
+                    "argNum": 2,
+                    "expr": "pp.extra"
+                }
+            ],
+            "position": "testdata/test1/test1.go:44:10"
+        },
+        {
+            "id": "{2} files remaining!",
+            "key": "%d files remaining!",
+            "message": "{2} files remaining!",
+            "translation": "",
+            "placeholders": [
+                {
+                    "id": "2",
+                    "string": "%[1]d",
+                    "type": "int",
+                    "underlyingType": "int",
+                    "argNum": 1,
+                    "expr": "2"
+                }
+            ],
+            "position": "testdata/test1/test1.go:51:10"
+        },
+        {
+            "id": "{N} more files remaining!",
+            "key": "%d more files remaining!",
+            "message": "{N} more files remaining!",
+            "translation": "",
+            "placeholders": [
+                {
+                    "id": "N",
+                    "string": "%[1]d",
+                    "type": "int",
+                    "underlyingType": "int",
+                    "argNum": 1,
+                    "expr": "n"
+                }
+            ],
+            "position": "testdata/test1/test1.go:56:10"
+        },
+        {
+            "id": "Use the following code for your discount: {ReferralCode}",
+            "key": "Use the following code for your discount: %d\n",
+            "message": "Use the following code for your discount: {ReferralCode}",
+            "translation": "",
+            "placeholders": [
+                {
+                    "id": "ReferralCode",
+                    "string": "%[1]d",
+                    "type": "./testdata/test1.referralCode",
+                    "underlyingType": "int",
+                    "argNum": 1,
+                    "expr": "c"
+                }
+            ],
+            "position": "testdata/test1/test1.go:64:10"
+        },
+        {
+            "id": [
+                "msgOutOfOrder",
+                "{Device} is out of order!"
+            ],
+            "key": "%s is out of order!",
+            "message": "{Device} is out of order!",
+            "translation": "",
+            "comment": "This comment wins.\n",
+            "placeholders": [
+                {
+                    "id": "Device",
+                    "string": "%[1]s",
+                    "type": "string",
+                    "underlyingType": "string",
+                    "argNum": 1,
+                    "expr": "device"
+                }
+            ],
+            "position": "testdata/test1/test1.go:70:10"
+        },
+        {
+            "id": "{Miles} miles traveled ({Miles_1})",
+            "key": "%.2[1]f miles traveled (%[1]f)",
+            "message": "{Miles} miles traveled ({Miles_1})",
+            "translation": "",
+            "placeholders": [
+                {
+                    "id": "Miles",
+                    "string": "%.2[1]f",
+                    "type": "float64",
+                    "underlyingType": "float64",
+                    "argNum": 1,
+                    "expr": "miles"
+                },
+                {
+                    "id": "Miles_1",
+                    "string": "%[1]f",
+                    "type": "float64",
+                    "underlyingType": "float64",
+                    "argNum": 1,
+                    "expr": "miles"
+                }
+            ],
+            "position": "testdata/test1/test1.go:74:10"
+        }
+    ]
+}
\ No newline at end of file
diff --git a/message/pipeline/testdata/test1/locales/de/out.gotext.json.want b/message/pipeline/testdata/test1/locales/de/out.gotext.json.want
new file mode 100755
index 0000000..9c7e594
--- /dev/null
+++ b/message/pipeline/testdata/test1/locales/de/out.gotext.json.want
@@ -0,0 +1,188 @@
+{
+    "language": "de",
+    "messages": [
+        {
+            "id": "Hello world!",
+            "key": "Hello world!\n",
+            "message": "Hello world!",
+            "translation": "",
+            "position": "testdata/test1/test1.go:19:10"
+        },
+        {
+            "id": "Hello {City}!",
+            "key": "Hello %s!\n",
+            "message": "Hello {City}!",
+            "translation": "",
+            "placeholders": [
+                {
+                    "id": "City",
+                    "string": "%[1]s",
+                    "type": "string",
+                    "underlyingType": "string",
+                    "argNum": 1,
+                    "expr": "city"
+                }
+            ],
+            "position": "testdata/test1/test1.go:24:10"
+        },
+        {
+            "id": "{Person} is visiting {Place}!",
+            "key": "%s is visiting %s!\n",
+            "message": "{Person} is visiting {Place}!",
+            "translation": "",
+            "placeholders": [
+                {
+                    "id": "Person",
+                    "string": "%[1]s",
+                    "type": "string",
+                    "underlyingType": "string",
+                    "argNum": 1,
+                    "expr": "person",
+                    "comment": "The person of matter."
+                },
+                {
+                    "id": "Place",
+                    "string": "%[2]s",
+                    "type": "string",
+                    "underlyingType": "string",
+                    "argNum": 2,
+                    "expr": "place",
+                    "comment": "Place the person is visiting."
+                }
+            ],
+            "position": "testdata/test1/test1.go:30:10"
+        },
+        {
+            "id": "{Person} is visiting {Place}!",
+            "key": "%[1]s is visiting %[3]s!\n",
+            "message": "{Person} is visiting {Place}!",
+            "translation": "",
+            "comment": "Field names are placeholders.",
+            "placeholders": [
+                {
+                    "id": "Person",
+                    "string": "%[1]s",
+                    "type": "string",
+                    "underlyingType": "string",
+                    "argNum": 1,
+                    "expr": "pp.Person"
+                },
+                {
+                    "id": "Place",
+                    "string": "%[3]s",
+                    "type": "string",
+                    "underlyingType": "string",
+                    "argNum": 3,
+                    "expr": "pp.Place",
+                    "comment": "Place the person is visiting."
+                },
+                {
+                    "id": "Extra",
+                    "string": "%[2]v",
+                    "type": "int",
+                    "underlyingType": "int",
+                    "argNum": 2,
+                    "expr": "pp.extra"
+                }
+            ],
+            "position": "testdata/test1/test1.go:44:10"
+        },
+        {
+            "id": "{2} files remaining!",
+            "key": "%d files remaining!",
+            "message": "{2} files remaining!",
+            "translation": "",
+            "placeholders": [
+                {
+                    "id": "2",
+                    "string": "%[1]d",
+                    "type": "int",
+                    "underlyingType": "int",
+                    "argNum": 1,
+                    "expr": "2"
+                }
+            ],
+            "position": "testdata/test1/test1.go:51:10"
+        },
+        {
+            "id": "{N} more files remaining!",
+            "key": "%d more files remaining!",
+            "message": "{N} more files remaining!",
+            "translation": "",
+            "placeholders": [
+                {
+                    "id": "N",
+                    "string": "%[1]d",
+                    "type": "int",
+                    "underlyingType": "int",
+                    "argNum": 1,
+                    "expr": "n"
+                }
+            ],
+            "position": "testdata/test1/test1.go:56:10"
+        },
+        {
+            "id": "Use the following code for your discount: {ReferralCode}",
+            "key": "Use the following code for your discount: %d\n",
+            "message": "Use the following code for your discount: {ReferralCode}",
+            "translation": "",
+            "placeholders": [
+                {
+                    "id": "ReferralCode",
+                    "string": "%[1]d",
+                    "type": "./testdata/test1.referralCode",
+                    "underlyingType": "int",
+                    "argNum": 1,
+                    "expr": "c"
+                }
+            ],
+            "position": "testdata/test1/test1.go:64:10"
+        },
+        {
+            "id": [
+                "msgOutOfOrder",
+                "{Device} is out of order!"
+            ],
+            "key": "%s is out of order!",
+            "message": "{Device} is out of order!",
+            "translation": "",
+            "comment": "This comment wins.\n",
+            "placeholders": [
+                {
+                    "id": "Device",
+                    "string": "%[1]s",
+                    "type": "string",
+                    "underlyingType": "string",
+                    "argNum": 1,
+                    "expr": "device"
+                }
+            ],
+            "position": "testdata/test1/test1.go:70:10"
+        },
+        {
+            "id": "{Miles} miles traveled ({Miles_1})",
+            "key": "%.2[1]f miles traveled (%[1]f)",
+            "message": "{Miles} miles traveled ({Miles_1})",
+            "translation": "",
+            "placeholders": [
+                {
+                    "id": "Miles",
+                    "string": "%.2[1]f",
+                    "type": "float64",
+                    "underlyingType": "float64",
+                    "argNum": 1,
+                    "expr": "miles"
+                },
+                {
+                    "id": "Miles_1",
+                    "string": "%[1]f",
+                    "type": "float64",
+                    "underlyingType": "float64",
+                    "argNum": 1,
+                    "expr": "miles"
+                }
+            ],
+            "position": "testdata/test1/test1.go:74:10"
+        }
+    ]
+}
\ No newline at end of file
diff --git a/message/pipeline/testdata/test1/locales/en-US/out.gotext.json b/message/pipeline/testdata/test1/locales/en-US/out.gotext.json
new file mode 100755
index 0000000..4d317af
--- /dev/null
+++ b/message/pipeline/testdata/test1/locales/en-US/out.gotext.json
@@ -0,0 +1,188 @@
+{
+    "language": "en-US",
+    "messages": [
+        {
+            "id": "Hello world!",
+            "key": "Hello world!\n",
+            "message": "Hello world!",
+            "translation": "",
+            "position": "testdata/test1/test1.go:19:10"
+        },
+        {
+            "id": "Hello {City}!",
+            "key": "Hello %s!\n",
+            "message": "Hello {City}!",
+            "translation": "",
+            "placeholders": [
+                {
+                    "id": "City",
+                    "string": "%[1]s",
+                    "type": "string",
+                    "underlyingType": "string",
+                    "argNum": 1,
+                    "expr": "city"
+                }
+            ],
+            "position": "testdata/test1/test1.go:24:10"
+        },
+        {
+            "id": "{Person} is visiting {Place}!",
+            "key": "%s is visiting %s!\n",
+            "message": "{Person} is visiting {Place}!",
+            "translation": "",
+            "placeholders": [
+                {
+                    "id": "Person",
+                    "string": "%[1]s",
+                    "type": "string",
+                    "underlyingType": "string",
+                    "argNum": 1,
+                    "expr": "person",
+                    "comment": "The person of matter."
+                },
+                {
+                    "id": "Place",
+                    "string": "%[2]s",
+                    "type": "string",
+                    "underlyingType": "string",
+                    "argNum": 2,
+                    "expr": "place",
+                    "comment": "Place the person is visiting."
+                }
+            ],
+            "position": "testdata/test1/test1.go:30:10"
+        },
+        {
+            "id": "{Person} is visiting {Place}!",
+            "key": "%[1]s is visiting %[3]s!\n",
+            "message": "{Person} is visiting {Place}!",
+            "translation": "",
+            "comment": "Field names are placeholders.",
+            "placeholders": [
+                {
+                    "id": "Person",
+                    "string": "%[1]s",
+                    "type": "string",
+                    "underlyingType": "string",
+                    "argNum": 1,
+                    "expr": "pp.Person"
+                },
+                {
+                    "id": "Place",
+                    "string": "%[3]s",
+                    "type": "string",
+                    "underlyingType": "string",
+                    "argNum": 3,
+                    "expr": "pp.Place",
+                    "comment": "Place the person is visiting."
+                },
+                {
+                    "id": "Extra",
+                    "string": "%[2]v",
+                    "type": "int",
+                    "underlyingType": "int",
+                    "argNum": 2,
+                    "expr": "pp.extra"
+                }
+            ],
+            "position": "testdata/test1/test1.go:44:10"
+        },
+        {
+            "id": "{2} files remaining!",
+            "key": "%d files remaining!",
+            "message": "{2} files remaining!",
+            "translation": "",
+            "placeholders": [
+                {
+                    "id": "2",
+                    "string": "%[1]d",
+                    "type": "int",
+                    "underlyingType": "int",
+                    "argNum": 1,
+                    "expr": "2"
+                }
+            ],
+            "position": "testdata/test1/test1.go:51:10"
+        },
+        {
+            "id": "{N} more files remaining!",
+            "key": "%d more files remaining!",
+            "message": "{N} more files remaining!",
+            "translation": "",
+            "placeholders": [
+                {
+                    "id": "N",
+                    "string": "%[1]d",
+                    "type": "int",
+                    "underlyingType": "int",
+                    "argNum": 1,
+                    "expr": "n"
+                }
+            ],
+            "position": "testdata/test1/test1.go:56:10"
+        },
+        {
+            "id": "Use the following code for your discount: {ReferralCode}",
+            "key": "Use the following code for your discount: %d\n",
+            "message": "Use the following code for your discount: {ReferralCode}",
+            "translation": "",
+            "placeholders": [
+                {
+                    "id": "ReferralCode",
+                    "string": "%[1]d",
+                    "type": "./testdata/test1.referralCode",
+                    "underlyingType": "int",
+                    "argNum": 1,
+                    "expr": "c"
+                }
+            ],
+            "position": "testdata/test1/test1.go:64:10"
+        },
+        {
+            "id": [
+                "msgOutOfOrder",
+                "{Device} is out of order!"
+            ],
+            "key": "%s is out of order!",
+            "message": "{Device} is out of order!",
+            "translation": "",
+            "comment": "This comment wins.\n",
+            "placeholders": [
+                {
+                    "id": "Device",
+                    "string": "%[1]s",
+                    "type": "string",
+                    "underlyingType": "string",
+                    "argNum": 1,
+                    "expr": "device"
+                }
+            ],
+            "position": "testdata/test1/test1.go:70:10"
+        },
+        {
+            "id": "{Miles} miles traveled ({Miles_1})",
+            "key": "%.2[1]f miles traveled (%[1]f)",
+            "message": "{Miles} miles traveled ({Miles_1})",
+            "translation": "",
+            "placeholders": [
+                {
+                    "id": "Miles",
+                    "string": "%.2[1]f",
+                    "type": "float64",
+                    "underlyingType": "float64",
+                    "argNum": 1,
+                    "expr": "miles"
+                },
+                {
+                    "id": "Miles_1",
+                    "string": "%[1]f",
+                    "type": "float64",
+                    "underlyingType": "float64",
+                    "argNum": 1,
+                    "expr": "miles"
+                }
+            ],
+            "position": "testdata/test1/test1.go:74:10"
+        }
+    ]
+}
\ No newline at end of file
diff --git a/message/pipeline/testdata/test1/locales/en-US/out.gotext.json.want b/message/pipeline/testdata/test1/locales/en-US/out.gotext.json.want
new file mode 100755
index 0000000..4d317af
--- /dev/null
+++ b/message/pipeline/testdata/test1/locales/en-US/out.gotext.json.want
@@ -0,0 +1,188 @@
+{
+    "language": "en-US",
+    "messages": [
+        {
+            "id": "Hello world!",
+            "key": "Hello world!\n",
+            "message": "Hello world!",
+            "translation": "",
+            "position": "testdata/test1/test1.go:19:10"
+        },
+        {
+            "id": "Hello {City}!",
+            "key": "Hello %s!\n",
+            "message": "Hello {City}!",
+            "translation": "",
+            "placeholders": [
+                {
+                    "id": "City",
+                    "string": "%[1]s",
+                    "type": "string",
+                    "underlyingType": "string",
+                    "argNum": 1,
+                    "expr": "city"
+                }
+            ],
+            "position": "testdata/test1/test1.go:24:10"
+        },
+        {
+            "id": "{Person} is visiting {Place}!",
+            "key": "%s is visiting %s!\n",
+            "message": "{Person} is visiting {Place}!",
+            "translation": "",
+            "placeholders": [
+                {
+                    "id": "Person",
+                    "string": "%[1]s",
+                    "type": "string",
+                    "underlyingType": "string",
+                    "argNum": 1,
+                    "expr": "person",
+                    "comment": "The person of matter."
+                },
+                {
+                    "id": "Place",
+                    "string": "%[2]s",
+                    "type": "string",
+                    "underlyingType": "string",
+                    "argNum": 2,
+                    "expr": "place",
+                    "comment": "Place the person is visiting."
+                }
+            ],
+            "position": "testdata/test1/test1.go:30:10"
+        },
+        {
+            "id": "{Person} is visiting {Place}!",
+            "key": "%[1]s is visiting %[3]s!\n",
+            "message": "{Person} is visiting {Place}!",
+            "translation": "",
+            "comment": "Field names are placeholders.",
+            "placeholders": [
+                {
+                    "id": "Person",
+                    "string": "%[1]s",
+                    "type": "string",
+                    "underlyingType": "string",
+                    "argNum": 1,
+                    "expr": "pp.Person"
+                },
+                {
+                    "id": "Place",
+                    "string": "%[3]s",
+                    "type": "string",
+                    "underlyingType": "string",
+                    "argNum": 3,
+                    "expr": "pp.Place",
+                    "comment": "Place the person is visiting."
+                },
+                {
+                    "id": "Extra",
+                    "string": "%[2]v",
+                    "type": "int",
+                    "underlyingType": "int",
+                    "argNum": 2,
+                    "expr": "pp.extra"
+                }
+            ],
+            "position": "testdata/test1/test1.go:44:10"
+        },
+        {
+            "id": "{2} files remaining!",
+            "key": "%d files remaining!",
+            "message": "{2} files remaining!",
+            "translation": "",
+            "placeholders": [
+                {
+                    "id": "2",
+                    "string": "%[1]d",
+                    "type": "int",
+                    "underlyingType": "int",
+                    "argNum": 1,
+                    "expr": "2"
+                }
+            ],
+            "position": "testdata/test1/test1.go:51:10"
+        },
+        {
+            "id": "{N} more files remaining!",
+            "key": "%d more files remaining!",
+            "message": "{N} more files remaining!",
+            "translation": "",
+            "placeholders": [
+                {
+                    "id": "N",
+                    "string": "%[1]d",
+                    "type": "int",
+                    "underlyingType": "int",
+                    "argNum": 1,
+                    "expr": "n"
+                }
+            ],
+            "position": "testdata/test1/test1.go:56:10"
+        },
+        {
+            "id": "Use the following code for your discount: {ReferralCode}",
+            "key": "Use the following code for your discount: %d\n",
+            "message": "Use the following code for your discount: {ReferralCode}",
+            "translation": "",
+            "placeholders": [
+                {
+                    "id": "ReferralCode",
+                    "string": "%[1]d",
+                    "type": "./testdata/test1.referralCode",
+                    "underlyingType": "int",
+                    "argNum": 1,
+                    "expr": "c"
+                }
+            ],
+            "position": "testdata/test1/test1.go:64:10"
+        },
+        {
+            "id": [
+                "msgOutOfOrder",
+                "{Device} is out of order!"
+            ],
+            "key": "%s is out of order!",
+            "message": "{Device} is out of order!",
+            "translation": "",
+            "comment": "This comment wins.\n",
+            "placeholders": [
+                {
+                    "id": "Device",
+                    "string": "%[1]s",
+                    "type": "string",
+                    "underlyingType": "string",
+                    "argNum": 1,
+                    "expr": "device"
+                }
+            ],
+            "position": "testdata/test1/test1.go:70:10"
+        },
+        {
+            "id": "{Miles} miles traveled ({Miles_1})",
+            "key": "%.2[1]f miles traveled (%[1]f)",
+            "message": "{Miles} miles traveled ({Miles_1})",
+            "translation": "",
+            "placeholders": [
+                {
+                    "id": "Miles",
+                    "string": "%.2[1]f",
+                    "type": "float64",
+                    "underlyingType": "float64",
+                    "argNum": 1,
+                    "expr": "miles"
+                },
+                {
+                    "id": "Miles_1",
+                    "string": "%[1]f",
+                    "type": "float64",
+                    "underlyingType": "float64",
+                    "argNum": 1,
+                    "expr": "miles"
+                }
+            ],
+            "position": "testdata/test1/test1.go:74:10"
+        }
+    ]
+}
\ No newline at end of file
diff --git a/message/pipeline/testdata/test1/locales/zh/out.gotext.json b/message/pipeline/testdata/test1/locales/zh/out.gotext.json
new file mode 100755
index 0000000..6f8aa67
--- /dev/null
+++ b/message/pipeline/testdata/test1/locales/zh/out.gotext.json
@@ -0,0 +1,188 @@
+{
+    "language": "zh",
+    "messages": [
+        {
+            "id": "Hello world!",
+            "key": "Hello world!\n",
+            "message": "Hello world!",
+            "translation": "",
+            "position": "testdata/test1/test1.go:19:10"
+        },
+        {
+            "id": "Hello {City}!",
+            "key": "Hello %s!\n",
+            "message": "Hello {City}!",
+            "translation": "",
+            "placeholders": [
+                {
+                    "id": "City",
+                    "string": "%[1]s",
+                    "type": "string",
+                    "underlyingType": "string",
+                    "argNum": 1,
+                    "expr": "city"
+                }
+            ],
+            "position": "testdata/test1/test1.go:24:10"
+        },
+        {
+            "id": "{Person} is visiting {Place}!",
+            "key": "%s is visiting %s!\n",
+            "message": "{Person} is visiting {Place}!",
+            "translation": "",
+            "placeholders": [
+                {
+                    "id": "Person",
+                    "string": "%[1]s",
+                    "type": "string",
+                    "underlyingType": "string",
+                    "argNum": 1,
+                    "expr": "person",
+                    "comment": "The person of matter."
+                },
+                {
+                    "id": "Place",
+                    "string": "%[2]s",
+                    "type": "string",
+                    "underlyingType": "string",
+                    "argNum": 2,
+                    "expr": "place",
+                    "comment": "Place the person is visiting."
+                }
+            ],
+            "position": "testdata/test1/test1.go:30:10"
+        },
+        {
+            "id": "{Person} is visiting {Place}!",
+            "key": "%[1]s is visiting %[3]s!\n",
+            "message": "{Person} is visiting {Place}!",
+            "translation": "",
+            "comment": "Field names are placeholders.",
+            "placeholders": [
+                {
+                    "id": "Person",
+                    "string": "%[1]s",
+                    "type": "string",
+                    "underlyingType": "string",
+                    "argNum": 1,
+                    "expr": "pp.Person"
+                },
+                {
+                    "id": "Place",
+                    "string": "%[3]s",
+                    "type": "string",
+                    "underlyingType": "string",
+                    "argNum": 3,
+                    "expr": "pp.Place",
+                    "comment": "Place the person is visiting."
+                },
+                {
+                    "id": "Extra",
+                    "string": "%[2]v",
+                    "type": "int",
+                    "underlyingType": "int",
+                    "argNum": 2,
+                    "expr": "pp.extra"
+                }
+            ],
+            "position": "testdata/test1/test1.go:44:10"
+        },
+        {
+            "id": "{2} files remaining!",
+            "key": "%d files remaining!",
+            "message": "{2} files remaining!",
+            "translation": "",
+            "placeholders": [
+                {
+                    "id": "2",
+                    "string": "%[1]d",
+                    "type": "int",
+                    "underlyingType": "int",
+                    "argNum": 1,
+                    "expr": "2"
+                }
+            ],
+            "position": "testdata/test1/test1.go:51:10"
+        },
+        {
+            "id": "{N} more files remaining!",
+            "key": "%d more files remaining!",
+            "message": "{N} more files remaining!",
+            "translation": "",
+            "placeholders": [
+                {
+                    "id": "N",
+                    "string": "%[1]d",
+                    "type": "int",
+                    "underlyingType": "int",
+                    "argNum": 1,
+                    "expr": "n"
+                }
+            ],
+            "position": "testdata/test1/test1.go:56:10"
+        },
+        {
+            "id": "Use the following code for your discount: {ReferralCode}",
+            "key": "Use the following code for your discount: %d\n",
+            "message": "Use the following code for your discount: {ReferralCode}",
+            "translation": "",
+            "placeholders": [
+                {
+                    "id": "ReferralCode",
+                    "string": "%[1]d",
+                    "type": "./testdata/test1.referralCode",
+                    "underlyingType": "int",
+                    "argNum": 1,
+                    "expr": "c"
+                }
+            ],
+            "position": "testdata/test1/test1.go:64:10"
+        },
+        {
+            "id": [
+                "msgOutOfOrder",
+                "{Device} is out of order!"
+            ],
+            "key": "%s is out of order!",
+            "message": "{Device} is out of order!",
+            "translation": "",
+            "comment": "This comment wins.\n",
+            "placeholders": [
+                {
+                    "id": "Device",
+                    "string": "%[1]s",
+                    "type": "string",
+                    "underlyingType": "string",
+                    "argNum": 1,
+                    "expr": "device"
+                }
+            ],
+            "position": "testdata/test1/test1.go:70:10"
+        },
+        {
+            "id": "{Miles} miles traveled ({Miles_1})",
+            "key": "%.2[1]f miles traveled (%[1]f)",
+            "message": "{Miles} miles traveled ({Miles_1})",
+            "translation": "",
+            "placeholders": [
+                {
+                    "id": "Miles",
+                    "string": "%.2[1]f",
+                    "type": "float64",
+                    "underlyingType": "float64",
+                    "argNum": 1,
+                    "expr": "miles"
+                },
+                {
+                    "id": "Miles_1",
+                    "string": "%[1]f",
+                    "type": "float64",
+                    "underlyingType": "float64",
+                    "argNum": 1,
+                    "expr": "miles"
+                }
+            ],
+            "position": "testdata/test1/test1.go:74:10"
+        }
+    ]
+}
\ No newline at end of file
diff --git a/message/pipeline/testdata/test1/locales/zh/out.gotext.json.want b/message/pipeline/testdata/test1/locales/zh/out.gotext.json.want
new file mode 100755
index 0000000..6f8aa67
--- /dev/null
+++ b/message/pipeline/testdata/test1/locales/zh/out.gotext.json.want
@@ -0,0 +1,188 @@
+{
+    "language": "zh",
+    "messages": [
+        {
+            "id": "Hello world!",
+            "key": "Hello world!\n",
+            "message": "Hello world!",
+            "translation": "",
+            "position": "testdata/test1/test1.go:19:10"
+        },
+        {
+            "id": "Hello {City}!",
+            "key": "Hello %s!\n",
+            "message": "Hello {City}!",
+            "translation": "",
+            "placeholders": [
+                {
+                    "id": "City",
+                    "string": "%[1]s",
+                    "type": "string",
+                    "underlyingType": "string",
+                    "argNum": 1,
+                    "expr": "city"
+                }
+            ],
+            "position": "testdata/test1/test1.go:24:10"
+        },
+        {
+            "id": "{Person} is visiting {Place}!",
+            "key": "%s is visiting %s!\n",
+            "message": "{Person} is visiting {Place}!",
+            "translation": "",
+            "placeholders": [
+                {
+                    "id": "Person",
+                    "string": "%[1]s",
+                    "type": "string",
+                    "underlyingType": "string",
+                    "argNum": 1,
+                    "expr": "person",
+                    "comment": "The person of matter."
+                },
+                {
+                    "id": "Place",
+                    "string": "%[2]s",
+                    "type": "string",
+                    "underlyingType": "string",
+                    "argNum": 2,
+                    "expr": "place",
+                    "comment": "Place the person is visiting."
+                }
+            ],
+            "position": "testdata/test1/test1.go:30:10"
+        },
+        {
+            "id": "{Person} is visiting {Place}!",
+            "key": "%[1]s is visiting %[3]s!\n",
+            "message": "{Person} is visiting {Place}!",
+            "translation": "",
+            "comment": "Field names are placeholders.",
+            "placeholders": [
+                {
+                    "id": "Person",
+                    "string": "%[1]s",
+                    "type": "string",
+                    "underlyingType": "string",
+                    "argNum": 1,
+                    "expr": "pp.Person"
+                },
+                {
+                    "id": "Place",
+                    "string": "%[3]s",
+                    "type": "string",
+                    "underlyingType": "string",
+                    "argNum": 3,
+                    "expr": "pp.Place",
+                    "comment": "Place the person is visiting."
+                },
+                {
+                    "id": "Extra",
+                    "string": "%[2]v",
+                    "type": "int",
+                    "underlyingType": "int",
+                    "argNum": 2,
+                    "expr": "pp.extra"
+                }
+            ],
+            "position": "testdata/test1/test1.go:44:10"
+        },
+        {
+            "id": "{2} files remaining!",
+            "key": "%d files remaining!",
+            "message": "{2} files remaining!",
+            "translation": "",
+            "placeholders": [
+                {
+                    "id": "2",
+                    "string": "%[1]d",
+                    "type": "int",
+                    "underlyingType": "int",
+                    "argNum": 1,
+                    "expr": "2"
+                }
+            ],
+            "position": "testdata/test1/test1.go:51:10"
+        },
+        {
+            "id": "{N} more files remaining!",
+            "key": "%d more files remaining!",
+            "message": "{N} more files remaining!",
+            "translation": "",
+            "placeholders": [
+                {
+                    "id": "N",
+                    "string": "%[1]d",
+                    "type": "int",
+                    "underlyingType": "int",
+                    "argNum": 1,
+                    "expr": "n"
+                }
+            ],
+            "position": "testdata/test1/test1.go:56:10"
+        },
+        {
+            "id": "Use the following code for your discount: {ReferralCode}",
+            "key": "Use the following code for your discount: %d\n",
+            "message": "Use the following code for your discount: {ReferralCode}",
+            "translation": "",
+            "placeholders": [
+                {
+                    "id": "ReferralCode",
+                    "string": "%[1]d",
+                    "type": "./testdata/test1.referralCode",
+                    "underlyingType": "int",
+                    "argNum": 1,
+                    "expr": "c"
+                }
+            ],
+            "position": "testdata/test1/test1.go:64:10"
+        },
+        {
+            "id": [
+                "msgOutOfOrder",
+                "{Device} is out of order!"
+            ],
+            "key": "%s is out of order!",
+            "message": "{Device} is out of order!",
+            "translation": "",
+            "comment": "This comment wins.\n",
+            "placeholders": [
+                {
+                    "id": "Device",
+                    "string": "%[1]s",
+                    "type": "string",
+                    "underlyingType": "string",
+                    "argNum": 1,
+                    "expr": "device"
+                }
+            ],
+            "position": "testdata/test1/test1.go:70:10"
+        },
+        {
+            "id": "{Miles} miles traveled ({Miles_1})",
+            "key": "%.2[1]f miles traveled (%[1]f)",
+            "message": "{Miles} miles traveled ({Miles_1})",
+            "translation": "",
+            "placeholders": [
+                {
+                    "id": "Miles",
+                    "string": "%.2[1]f",
+                    "type": "float64",
+                    "underlyingType": "float64",
+                    "argNum": 1,
+                    "expr": "miles"
+                },
+                {
+                    "id": "Miles_1",
+                    "string": "%[1]f",
+                    "type": "float64",
+                    "underlyingType": "float64",
+                    "argNum": 1,
+                    "expr": "miles"
+                }
+            ],
+            "position": "testdata/test1/test1.go:74:10"
+        }
+    ]
+}
\ No newline at end of file