message/pipeline: hoist importing from cmd/gotext

- cmd/gotext generate now extracts, so no need to
  create intermediate extraction file.
  (next step is to just have an update command)
- shared config creation code in cmd/text
- added tests in pipeline package

Change-Id: Ifdb69710554712a79bda79502e5e03a3f3a2c9d3
Reviewed-on: https://go-review.googlesource.com/83657
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/examples/extract_http/locales/extracted.gotext.json b/cmd/gotext/examples/extract_http/locales/extracted.gotext.json
deleted file mode 100755
index d0c4684..0000000
--- a/cmd/gotext/examples/extract_http/locales/extracted.gotext.json
+++ /dev/null
@@ -1,39 +0,0 @@
-{
-    "language": "en-US",
-    "messages": [
-        {
-            "id": "Hello {From}!\n",
-            "key": "Hello %s!\n",
-            "message": "Hello {From}!\n",
-            "translation": "",
-            "placeholders": [
-                {
-                    "id": "From",
-                    "string": "%[1]s",
-                    "type": "string",
-                    "underlyingType": "string",
-                    "argNum": 1,
-                    "expr": "r.Header.Get(\"From\")"
-                }
-            ],
-            "position": "golang.org/x/text/cmd/gotext/examples/extract_http/pkg/pkg.go:22:11"
-        },
-        {
-            "id": "Do you like your browser ({User_Agent})?\n",
-            "key": "Do you like your browser (%s)?\n",
-            "message": "Do you like your browser ({User_Agent})?\n",
-            "translation": "",
-            "placeholders": [
-                {
-                    "id": "User_Agent",
-                    "string": "%[1]s",
-                    "type": "string",
-                    "underlyingType": "string",
-                    "argNum": 1,
-                    "expr": "r.Header.Get(\"User-Agent\")"
-                }
-            ],
-            "position": "golang.org/x/text/cmd/gotext/examples/extract_http/pkg/pkg.go:24:11"
-        }
-    ]
-}
\ No newline at end of file
diff --git a/cmd/gotext/extract.go b/cmd/gotext/extract.go
index 20b94d3..221e776 100644
--- a/cmd/gotext/extract.go
+++ b/cmd/gotext/extract.go
@@ -11,7 +11,6 @@
 	"path/filepath"
 
 	"golang.org/x/text/internal"
-	"golang.org/x/text/language"
 	"golang.org/x/text/message/pipeline"
 )
 
@@ -22,12 +21,10 @@
 // - message rewriting
 
 var (
-	srcLang *string
-	lang    *string
+	lang *string
 )
 
 func init() {
-	srcLang = cmdExtract.Flag.String("srclang", "en-US", "the source-code language")
 	lang = cmdExtract.Flag.String("lang", "en-US", "comma-separated list of languages to process")
 }
 
@@ -37,34 +34,15 @@
 	Short:     "extracts strings to be translated from code",
 }
 
-func runExtract(cmd *Command, args []string) error {
-	tag, err := language.Parse(*srcLang)
-	if err != nil {
-		return wrap(err, "")
-	}
-	config := &pipeline.Config{
-		SourceLanguage: tag,
-		Packages:       args,
-	}
+func runExtract(cmd *Command, config *pipeline.Config, args []string) error {
+	config.Packages = args
 	state, err := pipeline.Extract(config)
 	if err != nil {
 		return wrap(err, "extract failed")
 	}
 	out := state.Extracted
 
-	data, err := json.MarshalIndent(out, "", "    ")
-	if err != nil {
-		return wrap(err, "")
-	}
-	os.MkdirAll(*dir, 0755)
-	// TODO: this file can probably go if we replace the extract + generate
-	// cycle with a init once and update cycle.
-	file := filepath.Join(*dir, extractFile)
-	if err := ioutil.WriteFile(file, data, 0644); err != nil {
-		return wrap(err, "could not create file")
-	}
-
-	langs := append(getLangs(), tag)
+	langs := append(getLangs(), config.SourceLanguage)
 	langs = internal.UniqueTags(langs)
 	for _, tag := range langs {
 		// TODO: inject translations from existing files to avoid retranslation.
diff --git a/cmd/gotext/generate.go b/cmd/gotext/generate.go
index a108dee..c8d5d79 100644
--- a/cmd/gotext/generate.go
+++ b/cmd/gotext/generate.go
@@ -5,16 +5,7 @@
 package main
 
 import (
-	"encoding/json"
-	"fmt"
-	"io/ioutil"
-	"os"
-	"path/filepath"
-	"regexp"
-	"strings"
-
 	"golang.org/x/text/message/pipeline"
-	"golang.org/x/tools/go/loader"
 )
 
 func init() {
@@ -31,74 +22,14 @@
 	Short:     "generates code to insert translated messages",
 }
 
-var transRe = regexp.MustCompile(`messages\.(.*)\.json`)
-
-func runGenerate(cmd *Command, args []string) error {
-
-	prog, err := loadPackages(&loader.Config{}, args)
+func runGenerate(cmd *Command, config *pipeline.Config, args []string) error {
+	config.Packages = args
+	s, err := pipeline.Extract(config)
 	if err != nil {
-		return wrap(err, "could not load package")
+		return wrap(err, "extraction failed")
 	}
-
-	pkgs := prog.InitialPackages()
-	if len(pkgs) != 1 {
-		return fmt.Errorf("more than one package selected: %v", pkgs)
+	if err := s.Import(); err != nil {
+		return wrap(err, "import failed")
 	}
-	pkg := pkgs[0].Pkg.Name()
-
-	// TODO: add in external input. Right now we assume that all files are
-	// manually created and stored in the textdata directory.
-
-	// Build up index of translations and original messages.
-	extracted := pipeline.Messages{}
-	translations := []pipeline.Messages{}
-
-	err = filepath.Walk(*dir, func(path string, f os.FileInfo, err error) error {
-		if err != nil {
-			return wrap(err, "loading data")
-		}
-		if f.IsDir() {
-			return nil
-		}
-		if f.Name() == extractFile {
-			b, err := ioutil.ReadFile(path)
-			if err != nil {
-				return wrap(err, "read file failed")
-			}
-			if err := json.Unmarshal(b, &extracted); err != nil {
-				return wrap(err, "unmarshal source failed")
-			}
-			return nil
-		}
-		if f.Name() == outFile {
-			return nil
-		}
-		if !strings.HasSuffix(path, gotextSuffix) {
-			return nil
-		}
-		b, err := ioutil.ReadFile(path)
-		if err != nil {
-			return wrap(err, "read file failed")
-		}
-		var locale pipeline.Messages
-		if err := json.Unmarshal(b, &locale); err != nil {
-			return wrap(err, "parsing translation file failed")
-		}
-		translations = append(translations, locale)
-		return nil
-	})
-	if err != nil {
-		return err
-	}
-
-	w := os.Stdout
-	if *out != "" {
-		w, err = os.Create(*out)
-		if err != nil {
-			return wrap(err, "create file failed")
-		}
-	}
-
-	_, err = pipeline.Generate(w, pkg, &extracted, translations...)
-	return err
+	return wrap(s.Generate(), "generation failed")
 }
diff --git a/cmd/gotext/main.go b/cmd/gotext/main.go
index f3f50d7..1e6c3aa 100644
--- a/cmd/gotext/main.go
+++ b/cmd/gotext/main.go
@@ -25,6 +25,8 @@
 	"unicode"
 	"unicode/utf8"
 
+	"golang.org/x/text/message/pipeline"
+
 	"golang.org/x/text/language"
 	"golang.org/x/tools/go/buildutil"
 )
@@ -33,7 +35,22 @@
 	flag.Var((*buildutil.TagsFlag)(&build.Default.BuildTags), "tags", buildutil.TagsFlagDoc)
 }
 
-var dir = flag.String("dir", "locales", "default subdirectory to store translation files")
+var (
+	srcLang = flag.String("srclang", "en-US", "the source-code language")
+	dir     = flag.String("dir", "locales", "default subdirectory to store translation files")
+)
+
+func config() (*pipeline.Config, error) {
+	tag, err := language.Parse(*srcLang)
+	if err != nil {
+		return nil, wrap(err, "invalid srclang")
+	}
+	return &pipeline.Config{
+		SourceLanguage:      tag,
+		TranslationsPattern: `messages\.(.*)\.json`,
+		GenFile:             *out,
+	}, nil
+}
 
 // NOTE: the Command struct is copied from the go tool in core.
 
@@ -42,7 +59,7 @@
 type Command struct {
 	// Run runs the command.
 	// The args are the arguments after the command name.
-	Run func(cmd *Command, args []string) error
+	Run func(cmd *Command, c *pipeline.Config, args []string) error
 
 	// UsageLine is the one-line usage message.
 	// The first word in the line is taken to be the command name.
@@ -124,7 +141,11 @@
 			cmd.Flag.Usage = func() { cmd.Usage() }
 			cmd.Flag.Parse(args[1:])
 			args = cmd.Flag.Args()
-			if err := cmd.Run(cmd, args); err != nil {
+			config, err := config()
+			if err != nil {
+				fatalf("gotext: %+v", err)
+			}
+			if err := cmd.Run(cmd, config, args); err != nil {
 				fatalf("gotext: %+v", err)
 			}
 			exit()
diff --git a/cmd/gotext/rewrite.go b/cmd/gotext/rewrite.go
index a35b727..3ee9555 100644
--- a/cmd/gotext/rewrite.go
+++ b/cmd/gotext/rewrite.go
@@ -38,7 +38,7 @@
 `,
 }
 
-func runRewrite(cmd *Command, args []string) error {
+func runRewrite(cmd *Command, _ *pipeline.Config, args []string) error {
 	w := os.Stdout
 	if *overwrite {
 		w = nil
diff --git a/message/pipeline/generate.go b/message/pipeline/generate.go
index 2b1c875..f21568d 100644
--- a/message/pipeline/generate.go
+++ b/message/pipeline/generate.go
@@ -6,7 +6,9 @@
 
 import (
 	"fmt"
+	"go/build"
 	"io"
+	"path/filepath"
 	"regexp"
 	"sort"
 	"strings"
@@ -25,8 +27,12 @@
 
 // Generate writes a Go file that defines a Catalog with translated messages.
 func (s *State) Generate() error {
-	filename := s.Config.CatalogFile
-	prog, err := loadPackages(&loader.Config{}, []string{filename})
+	path := s.Config.GenPackage
+	if path == "" {
+		path = "."
+	}
+	isDir := path[0] == '.'
+	prog, err := loadPackages(&loader.Config{}, []string{path})
 	if err != nil {
 		return wrap(err, "could not load package")
 	}
@@ -40,7 +46,12 @@
 	if err != nil {
 		return err
 	}
-	cw.WriteGoFile(filename, pkg) // TODO: WriteGoFile should return error.
+	if !isDir {
+		gopath := build.Default.GOPATH
+		path = filepath.Join(gopath, filepath.FromSlash(pkgs[0].Pkg.Path()))
+	}
+	path = filepath.Join(path, s.Config.GenFile)
+	cw.WriteGoFile(path, pkg) // TODO: WriteGoFile should return error.
 	return err
 }
 
diff --git a/message/pipeline/pipeline.go b/message/pipeline/pipeline.go
index 848a47a..876e470 100644
--- a/message/pipeline/pipeline.go
+++ b/message/pipeline/pipeline.go
@@ -8,10 +8,17 @@
 package pipeline
 
 import (
+	"bytes"
+	"encoding/json"
 	"fmt"
 	"go/build"
 	"go/parser"
+	"io/ioutil"
 	"log"
+	"path/filepath"
+	"regexp"
+	"strings"
+	"text/template"
 
 	"golang.org/x/text/language"
 	"golang.org/x/tools/go/loader"
@@ -20,7 +27,7 @@
 const (
 	extractFile  = "extracted.gotext.json"
 	outFile      = "out.gotext.json"
-	gotextSuffix = ".gotext.json"
+	gotextSuffix = "gotext.json"
 )
 
 // Config contains configuration for the translation pipeline.
@@ -41,8 +48,14 @@
 	// Dir is the root dir for all operations.
 	Dir string
 
-	// TranslationsPattern is a regular expression for input translation files
-	// that match anywhere in the directory structure rooted at Dir.
+	// TranslationsPattern is a regular expression to match incoming translation
+	// files. These files may appear in any directory rooted at Dir.
+	// language for the translation files is determined as follows:
+	//   1. From the Language field in the file.
+	//   2. If not present, from a valid language tag in the filename, separated
+	//      by dots (e.g. "en-US.json" or "incoming.pt_PT.xmb").
+	//   3. If not present, from a the closest subdirectory in which the file
+	//      is contained that parses as a valid language tag.
 	TranslationsPattern string
 
 	// OutPattern defines the location for translation files for a certain
@@ -65,9 +78,13 @@
 
 	// --- Generation
 
-	// CatalogFile may be in a different package. It is not defined, it will
+	// GenFile may be in a different package. It is not defined, it will
 	// be written to stdout.
-	CatalogFile string
+	GenFile string
+
+	// GenPackage is the package or relative path into which to generate the
+	// file. If not specified it is relative to the current directory.
+	GenPackage string
 
 	// DeclareVar defines a variable to which to assing the generated Catalog.
 	DeclareVar string
@@ -117,30 +134,106 @@
 	Translations []Messages
 }
 
-// A full-cycle pipeline example:
-//
-//  func updateAll(c *Config) error {
-//  	s := Extract(c)
-//  	s.Import()
-//  	s.Merge()
-//  	for range s.Config.Actions {
-//  		if s.Err != nil {
-//  			return s.Err
-//  		}
-//  		//  TODO: do the actions.
-//  	}
-//  	if err := s.Export(); err != nil {
-//  		return err
-//  	}
-//  	if err := s.Generate(); err != nil {
-//  		return err
-//  	}
-//  	return nil
-//  }
+func (s *State) dir() string {
+	if d := s.Config.Dir; d != "" {
+		return d
+	}
+	return "./locales"
+}
+
+func outPattern(s *State) (string, error) {
+	c := s.Config
+	pat := c.OutPattern
+	if pat == "" {
+		pat = "{{.Dir}}/{{.Language}}/out.{{.Ext}}"
+	}
+
+	ext := c.Ext
+	if ext == "" {
+		ext = c.Format
+	}
+	if ext == "" {
+		ext = gotextSuffix
+	}
+	t, err := template.New("").Parse(pat)
+	if err != nil {
+		return "", wrap(err, "error parsing template")
+	}
+	buf := bytes.Buffer{}
+	err = t.Execute(&buf, map[string]string{
+		"Dir":      s.dir(),
+		"Language": "%s",
+		"Ext":      ext,
+	})
+	return filepath.FromSlash(buf.String()), wrap(err, "incorrect OutPattern")
+}
+
+var transRE = regexp.MustCompile(`.*\.` + gotextSuffix)
 
 // Import loads existing translation files.
 func (s *State) Import() error {
-	panic("unimplemented")
+	outPattern, err := outPattern(s)
+	if err != nil {
+		return err
+	}
+	re := transRE
+	if pat := s.Config.TranslationsPattern; pat != "" {
+		if re, err = regexp.Compile(pat); err != nil {
+			return wrapf(err, "error parsing regexp %q", s.Config.TranslationsPattern)
+		}
+	}
+	x := importer{s, outPattern, re}
+	return x.walkImport(s.dir(), s.Config.SourceLanguage)
+}
+
+type importer struct {
+	state      *State
+	outPattern string
+	transFile  *regexp.Regexp
+}
+
+func (i *importer) walkImport(path string, tag language.Tag) error {
+	files, err := ioutil.ReadDir(path)
+	if err != nil {
+		return nil
+	}
+	for _, f := range files {
+		name := f.Name()
+		tag := tag
+		if f.IsDir() {
+			if t, err := language.Parse(name); err == nil {
+				tag = t
+			}
+			// We ignore errors
+			if err := i.walkImport(filepath.Join(path, name), tag); err != nil {
+				return err
+			}
+			continue
+		}
+		for _, l := range strings.Split(name, ".") {
+			if t, err := language.Parse(l); err == nil {
+				tag = t
+			}
+		}
+		file := filepath.Join(path, name)
+		// TODO: Should we skip files that match output files?
+		if fmt.Sprintf(i.outPattern, tag) == file {
+			continue
+		}
+		// TODO: handle different file formats.
+		if !i.transFile.MatchString(name) {
+			continue
+		}
+		b, err := ioutil.ReadFile(file)
+		if err != nil {
+			return wrap(err, "read file failed")
+		}
+		var translations Messages
+		if err := json.Unmarshal(b, &translations); err != nil {
+			return wrap(err, "parsing translation file failed")
+		}
+		i.state.Translations = append(i.state.Translations, translations)
+	}
 	return nil
 }
 
@@ -160,9 +253,15 @@
 // NOTE: The command line tool already prefixes with "gotext:".
 var (
 	wrap = func(err error, msg string) error {
+		if err == nil {
+			return nil
+		}
 		return fmt.Errorf("%s: %v", msg, err)
 	}
 	wrapf = func(err error, msg string, args ...interface{}) error {
+		if err == nil {
+			return nil
+		}
 		return wrap(err, fmt.Sprintf(msg, args...))
 	}
 	errorf = fmt.Errorf
diff --git a/message/pipeline/pipeline_test.go b/message/pipeline/pipeline_test.go
new file mode 100644
index 0000000..cb1d3b5
--- /dev/null
+++ b/message/pipeline/pipeline_test.go
@@ -0,0 +1,113 @@
+// 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 pipeline
+
+import (
+	"bufio"
+	"bytes"
+	"encoding/json"
+	"flag"
+	"fmt"
+	"io/ioutil"
+	"os"
+	"path"
+	"path/filepath"
+	"testing"
+)
+
+var genFiles = flag.Bool("gen", false, "generate output files instead of comparing")
+
+func TestFullCycle(t *testing.T) {
+	const path = "./testdata"
+	dirs, err := ioutil.ReadDir(path)
+	if err != nil {
+		t.Fatal(err)
+	}
+	for _, f := range dirs {
+		t.Run(f.Name(), func(t *testing.T) {
+			chk := func(t *testing.T, err error) {
+				t.Helper()
+				if err != nil {
+					t.Fatal(err)
+				}
+			}
+			dir := filepath.Join(path, f.Name())
+			pkgPath := fmt.Sprintf("%s/%s", path, f.Name())
+			config := Config{
+				Packages:   []string{pkgPath},
+				Dir:        filepath.Join(dir, "locales"),
+				GenFile:    "catalog_gen.go",
+				GenPackage: pkgPath,
+			}
+			// TODO: load config if available.
+			s, err := Extract(&config)
+			chk(t, err)
+			chk(t, s.Import())
+			// chk(t, s.Merge()) // TODO
+			// TODO:
+			//  for range s.Config.Actions {
+			//  	//  TODO: do the actions.
+			//  }
+			// chk(t, s.Export()) // TODO
+			chk(t, s.Generate())
+
+			writeJSON(t, filepath.Join(dir, "extracted.gotext.json"), s.Extracted)
+			checkOutput(t, dir)
+		})
+	}
+}
+
+func checkOutput(t *testing.T, p string) {
+	filepath.Walk(p, func(p string, f os.FileInfo, err error) error {
+		if f.IsDir() {
+			return nil
+		}
+		if filepath.Ext(p) != ".want" {
+			return nil
+		}
+		gotFile := p[:len(p)-len(".want")]
+		got, err := ioutil.ReadFile(gotFile)
+		if err != nil {
+			t.Errorf("failed to read %q", p)
+			return nil
+		}
+		if *genFiles {
+			if err := ioutil.WriteFile(p, got, 0644); err != nil {
+				t.Fatal(err)
+			}
+		}
+		want, err := ioutil.ReadFile(p)
+		if err != nil {
+			t.Errorf("failed to read %q", p)
+		} else {
+			scanGot := bufio.NewScanner(bytes.NewReader(got))
+			scanWant := bufio.NewScanner(bytes.NewReader(want))
+			line := 0
+			for scanGot.Scan() && scanWant.Scan() {
+				got := path.Clean(filepath.ToSlash(scanGot.Text()))
+				want := path.Clean(filepath.ToSlash(scanWant.Text()))
+				if got != want {
+					t.Errorf("file %q differs from .want file at line %d:\n\t%s\n\t%s", gotFile, line, got, want)
+					break
+				}
+				line++
+			}
+			if scanGot.Scan() || scanWant.Scan() {
+				t.Errorf("file %q differs from .want file at line %d.", gotFile, line)
+			}
+		}
+		return nil
+	})
+}
+
+func writeJSON(t *testing.T, path string, x interface{}) {
+	data, err := json.MarshalIndent(x, "", "    ")
+	if err != nil {
+		t.Fatal(err)
+	}
+	if err := ioutil.WriteFile(path, data, 0644); err != nil {
+		t.Fatal(err)
+	}
+}
diff --git a/message/pipeline/testdata/test1/catalog_gen.go b/message/pipeline/testdata/test1/catalog_gen.go
new file mode 100644
index 0000000..76b0bf0
--- /dev/null
+++ b/message/pipeline/testdata/test1/catalog_gen.go
@@ -0,0 +1,81 @@
+// Code generated by running "go generate" in golang.org/x/text. DO NOT EDIT.
+
+package main
+
+import (
+	"golang.org/x/text/language"
+	"golang.org/x/text/message"
+	"golang.org/x/text/message/catalog"
+)
+
+type dictionary struct {
+	index []uint32
+	data  string
+}
+
+func (d *dictionary) Lookup(key string) (data string, ok bool) {
+	p := messageKeyToIndex[key]
+	start, end := d.index[p], d.index[p+1]
+	if start == end {
+		return "", false
+	}
+	return d.data[start:end], true
+}
+
+func init() {
+	dict := map[string]catalog.Dictionary{
+		"de":    &dictionary{index: deIndex, data: deData},
+		"en_US": &dictionary{index: en_USIndex, data: en_USData},
+		"zh":    &dictionary{index: zhIndex, data: zhData},
+	}
+	fallback := language.MustParse("und")
+	cat, err := catalog.NewFromMap(dict, catalog.Fallback(fallback))
+	if err != nil {
+		panic(err)
+	}
+	message.DefaultCatalog = cat
+}
+
+var messageKeyToIndex = map[string]int{
+	"%.2[1]f miles traveled (%[1]f)": 7,
+	"%[1]s is visiting %[3]s!\n":     3,
+	"%d files remaining!":            4,
+	"%d more files remaining!":       5,
+	"%s is out of order!":            6,
+	"%s is visiting %s!\n":           2,
+	"Hello %s!\n":                    1,
+	"Hello world!\n":                 0,
+}
+
+var deIndex = []uint32{ // 9 elements
+	0x00000000, 0x0000000d, 0x0000001b, 0x00000031,
+	0x00000047, 0x00000065, 0x00000084, 0x00000084,
+	0x00000084,
+} // Size: 60 bytes
+
+const deData string = "" + // Size: 132 bytes
+	"\x02Hallo Welt!\x0a\x02Hallo %[1]s!\x0a\x02%[1]s besucht %[2]s!\x0a\x02%" +
+	"[1]s besucht %[3]s!\x0a\x02Noch zwei Bestände zu gehen!\x02Noch %[1]d Be" +
+	"stände zu gehen!"
+
+var en_USIndex = []uint32{ // 9 elements
+	0x00000000, 0x0000000e, 0x0000001c, 0x00000036,
+	0x00000050, 0x00000050, 0x00000093, 0x000000aa,
+	0x000000c9,
+} // Size: 60 bytes
+
+const en_USData string = "" + // Size: 201 bytes
+	"\x02Hello world!\x0a\x02Hello %[1]s!\x0a\x02%[1]s is visiting %[2]s!\x0a" +
+	"\x02%[1]s is visiting %[3]s!\x0a\x04\x01\x81\x01\x00\x02\x14\x02One file" +
+	" remaining!\x00&\x02There are %[1]d more files remaining!\x02%[1]s is ou" +
+	"t of order!\x02%.2[1]f miles traveled (%[1]f)"
+
+var zhIndex = []uint32{ // 9 elements
+	0x00000000, 0x00000000, 0x00000000, 0x00000000,
+	0x00000000, 0x00000000, 0x00000000, 0x00000000,
+	0x00000000,
+} // Size: 60 bytes
+
+const zhData string = ""
+
+// Total table size 513 bytes (0KiB); checksum: B21180E3
diff --git a/cmd/gotext/examples/extract/locales/extracted.gotext.json b/message/pipeline/testdata/test1/extracted.gotext.json
old mode 100755
new mode 100644
similarity index 77%
rename from cmd/gotext/examples/extract/locales/extracted.gotext.json
rename to message/pipeline/testdata/test1/extracted.gotext.json
index 27d8b56..dfb9153
--- a/cmd/gotext/examples/extract/locales/extracted.gotext.json
+++ b/message/pipeline/testdata/test1/extracted.gotext.json
@@ -1,12 +1,12 @@
 {
-    "language": "en-US",
+    "language": "und",
     "messages": [
         {
             "id": "Hello world!\n",
             "key": "Hello world!\n",
             "message": "Hello world!\n",
             "translation": "",
-            "position": "golang.org/x/text/cmd/gotext/examples/extract/main.go:28:10"
+            "position": "testdata/test1/test1.go:19:10"
         },
         {
             "id": "Hello {City}!\n",
@@ -23,25 +23,7 @@
                     "expr": "city"
                 }
             ],
-            "position": "golang.org/x/text/cmd/gotext/examples/extract/main.go:32:10"
-        },
-        {
-            "id": "Hello {Town}!\n",
-            "key": "Hello %s!\n",
-            "message": "Hello {Town}!\n",
-            "translation": "",
-            "placeholders": [
-                {
-                    "id": "Town",
-                    "string": "%[1]s",
-                    "type": "string",
-                    "underlyingType": "string",
-                    "argNum": 1,
-                    "expr": "town",
-                    "comment": "Town"
-                }
-            ],
-            "position": "golang.org/x/text/cmd/gotext/examples/extract/main.go:36:10"
+            "position": "testdata/test1/test1.go:24:10"
         },
         {
             "id": "{Person} is visiting {Place}!\n",
@@ -68,14 +50,14 @@
                     "comment": "Place the person is visiting."
                 }
             ],
-            "position": "golang.org/x/text/cmd/gotext/examples/extract/main.go:41:10"
+            "position": "testdata/test1/test1.go:30:10"
         },
         {
             "id": "{Person} is visiting {Place}!\n",
             "key": "%[1]s is visiting %[3]s!\n",
             "message": "{Person} is visiting {Place}!\n",
             "translation": "",
-            "comment": "Person visiting a place.",
+            "comment": "Field names are placeholders.",
             "placeholders": [
                 {
                     "id": "Person",
@@ -103,7 +85,7 @@
                     "expr": "pp.extra"
                 }
             ],
-            "position": "golang.org/x/text/cmd/gotext/examples/extract/main.go:56:10"
+            "position": "testdata/test1/test1.go:44:10"
         },
         {
             "id": "{2} files remaining!",
@@ -120,7 +102,7 @@
                     "expr": "2"
                 }
             ],
-            "position": "golang.org/x/text/cmd/gotext/examples/extract/main.go:63:10"
+            "position": "testdata/test1/test1.go:51:10"
         },
         {
             "id": "{N} more files remaining!",
@@ -137,7 +119,7 @@
                     "expr": "n"
                 }
             ],
-            "position": "golang.org/x/text/cmd/gotext/examples/extract/main.go:68:10"
+            "position": "testdata/test1/test1.go:56:10"
         },
         {
             "id": "Use the following code for your discount: {ReferralCode}\n",
@@ -148,13 +130,13 @@
                 {
                     "id": "ReferralCode",
                     "string": "%[1]d",
-                    "type": "golang.org/x/text/cmd/gotext/examples/extract.referralCode",
+                    "type": "./testdata/test1.referralCode",
                     "underlyingType": "int",
                     "argNum": 1,
                     "expr": "c"
                 }
             ],
-            "position": "golang.org/x/text/cmd/gotext/examples/extract/main.go:74:10"
+            "position": "testdata/test1/test1.go:64:10"
         },
         {
             "id": [
@@ -164,7 +146,7 @@
             "key": "%s is out of order!",
             "message": "{Device} is out of order!",
             "translation": "",
-            "comment": "FOO\n",
+            "comment": "This comment wins.\n",
             "placeholders": [
                 {
                     "id": "Device",
@@ -175,7 +157,7 @@
                     "expr": "device"
                 }
             ],
-            "position": "golang.org/x/text/cmd/gotext/examples/extract/main.go:82:10"
+            "position": "testdata/test1/test1.go:70:10"
         },
         {
             "id": "{Miles} miles traveled ({Miles_1})",
@@ -200,7 +182,7 @@
                     "expr": "miles"
                 }
             ],
-            "position": "golang.org/x/text/cmd/gotext/examples/extract/main.go:86:10"
+            "position": "testdata/test1/test1.go:74:10"
         }
     ]
 }
\ No newline at end of file
diff --git a/cmd/gotext/examples/extract/locales/extracted.gotext.json b/message/pipeline/testdata/test1/extracted.gotext.json.want
old mode 100755
new mode 100644
similarity index 77%
copy from cmd/gotext/examples/extract/locales/extracted.gotext.json
copy to message/pipeline/testdata/test1/extracted.gotext.json.want
index 27d8b56..dfb9153
--- a/cmd/gotext/examples/extract/locales/extracted.gotext.json
+++ b/message/pipeline/testdata/test1/extracted.gotext.json.want
@@ -1,12 +1,12 @@
 {
-    "language": "en-US",
+    "language": "und",
     "messages": [
         {
             "id": "Hello world!\n",
             "key": "Hello world!\n",
             "message": "Hello world!\n",
             "translation": "",
-            "position": "golang.org/x/text/cmd/gotext/examples/extract/main.go:28:10"
+            "position": "testdata/test1/test1.go:19:10"
         },
         {
             "id": "Hello {City}!\n",
@@ -23,25 +23,7 @@
                     "expr": "city"
                 }
             ],
-            "position": "golang.org/x/text/cmd/gotext/examples/extract/main.go:32:10"
-        },
-        {
-            "id": "Hello {Town}!\n",
-            "key": "Hello %s!\n",
-            "message": "Hello {Town}!\n",
-            "translation": "",
-            "placeholders": [
-                {
-                    "id": "Town",
-                    "string": "%[1]s",
-                    "type": "string",
-                    "underlyingType": "string",
-                    "argNum": 1,
-                    "expr": "town",
-                    "comment": "Town"
-                }
-            ],
-            "position": "golang.org/x/text/cmd/gotext/examples/extract/main.go:36:10"
+            "position": "testdata/test1/test1.go:24:10"
         },
         {
             "id": "{Person} is visiting {Place}!\n",
@@ -68,14 +50,14 @@
                     "comment": "Place the person is visiting."
                 }
             ],
-            "position": "golang.org/x/text/cmd/gotext/examples/extract/main.go:41:10"
+            "position": "testdata/test1/test1.go:30:10"
         },
         {
             "id": "{Person} is visiting {Place}!\n",
             "key": "%[1]s is visiting %[3]s!\n",
             "message": "{Person} is visiting {Place}!\n",
             "translation": "",
-            "comment": "Person visiting a place.",
+            "comment": "Field names are placeholders.",
             "placeholders": [
                 {
                     "id": "Person",
@@ -103,7 +85,7 @@
                     "expr": "pp.extra"
                 }
             ],
-            "position": "golang.org/x/text/cmd/gotext/examples/extract/main.go:56:10"
+            "position": "testdata/test1/test1.go:44:10"
         },
         {
             "id": "{2} files remaining!",
@@ -120,7 +102,7 @@
                     "expr": "2"
                 }
             ],
-            "position": "golang.org/x/text/cmd/gotext/examples/extract/main.go:63:10"
+            "position": "testdata/test1/test1.go:51:10"
         },
         {
             "id": "{N} more files remaining!",
@@ -137,7 +119,7 @@
                     "expr": "n"
                 }
             ],
-            "position": "golang.org/x/text/cmd/gotext/examples/extract/main.go:68:10"
+            "position": "testdata/test1/test1.go:56:10"
         },
         {
             "id": "Use the following code for your discount: {ReferralCode}\n",
@@ -148,13 +130,13 @@
                 {
                     "id": "ReferralCode",
                     "string": "%[1]d",
-                    "type": "golang.org/x/text/cmd/gotext/examples/extract.referralCode",
+                    "type": "./testdata/test1.referralCode",
                     "underlyingType": "int",
                     "argNum": 1,
                     "expr": "c"
                 }
             ],
-            "position": "golang.org/x/text/cmd/gotext/examples/extract/main.go:74:10"
+            "position": "testdata/test1/test1.go:64:10"
         },
         {
             "id": [
@@ -164,7 +146,7 @@
             "key": "%s is out of order!",
             "message": "{Device} is out of order!",
             "translation": "",
-            "comment": "FOO\n",
+            "comment": "This comment wins.\n",
             "placeholders": [
                 {
                     "id": "Device",
@@ -175,7 +157,7 @@
                     "expr": "device"
                 }
             ],
-            "position": "golang.org/x/text/cmd/gotext/examples/extract/main.go:82:10"
+            "position": "testdata/test1/test1.go:70:10"
         },
         {
             "id": "{Miles} miles traveled ({Miles_1})",
@@ -200,7 +182,7 @@
                     "expr": "miles"
                 }
             ],
-            "position": "golang.org/x/text/cmd/gotext/examples/extract/main.go:86:10"
+            "position": "testdata/test1/test1.go:74:10"
         }
     ]
 }
\ No newline at end of file
diff --git a/message/pipeline/testdata/test1/locales/de/messages.gotext.json b/message/pipeline/testdata/test1/locales/de/messages.gotext.json
new file mode 100755
index 0000000..a4a46ed
--- /dev/null
+++ b/message/pipeline/testdata/test1/locales/de/messages.gotext.json
@@ -0,0 +1,123 @@
+{
+    "language": "de",
+    "messages": [
+        {
+            "id": "Hello world!\n",
+            "key": "Hello world!\n",
+            "message": "Hello world!\n",
+            "translation": "Hallo Welt!\n"
+        },
+        {
+            "id": "Hello {City}!\n",
+            "key": "Hello %s!\n",
+            "message": "Hello {City}!\n",
+            "translation": "Hallo {City}!\n",
+            "placeholders": [
+                {
+                    "id": "City",
+                    "string": "%[1]s"
+                }
+            ]
+        },
+        {
+            "id": "{Person} is visiting {Place}!\n",
+            "key": "%s is visiting %s!\n",
+            "message": "{Person} is visiting {Place}!\n",
+            "translation": "{Person} besucht {Place}!\n",
+            "placeholders": [
+                {
+                    "id": "Person",
+                    "string": "%[1]s"
+                },
+                {
+                    "id": "Place",
+                    "string": "%[2]s"
+                }
+            ]
+        },
+        {
+            "id": "{Person} is visiting {Place}!\n",
+            "key": "%[1]s is visiting %[3]s!\n",
+            "message": "{Person} is visiting {Place}!\n",
+            "translation": "{Person} besucht {Place}!\n",
+            "placeholders": [
+                {
+                    "id": "Person",
+                    "string": "%[1]s"
+                },
+                {
+                    "id": "Place",
+                    "string": "%[3]s"
+                },
+                {
+                    "id": "Extra",
+                    "string": "%[2]v"
+                }
+            ]
+        },
+        {
+            "id": "{2} files remaining!",
+            "key": "%d files remaining!",
+            "message": "{N} files remaining!",
+            "translation": "Noch zwei Bestände zu gehen!",
+            "placeholders": [
+                {
+                    "id": "2",
+                    "string": "%[1]d"
+                }
+            ]
+        },
+        {
+            "id": "{N} more files remaining!",
+            "key": "%d more files remaining!",
+            "message": "{N} more files remaining!",
+            "translation": "Noch {N} Bestände zu gehen!",
+            "placeholders": [
+                {
+                    "id": "N",
+                    "string": "%[1]d"
+                }
+            ]
+        },
+        {
+            "id": "Use the following code for your discount: {ReferralCode}\n",
+            "key": "Use the following code for your discount: %d\n",
+            "message": "Use the following code for your discount: {ReferralCode}\n",
+            "translation": "",
+            "placeholders": [
+                {
+                    "id": "ReferralCode",
+                    "string": "%[1]d"
+                }
+            ]
+        },
+        {
+            "id": [ "msgOutOfOrder", "{Device} is out of order!" ],
+            "key": "%s is out of order!",
+            "message": "{Device} is out of order!",
+            "translation": "",
+            "placeholders": [
+                {
+                    "id": "Device",
+                    "string": "%[1]s"
+                }
+            ]
+        },
+        {
+            "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"
+                },
+                {
+                    "id": "Miles_1",
+                    "string": "%[1]f"
+                }
+            ]
+        }
+    ]
+}
diff --git a/message/pipeline/testdata/test1/locales/en-US/messages.gotext.json b/message/pipeline/testdata/test1/locales/en-US/messages.gotext.json
new file mode 100755
index 0000000..bf97577
--- /dev/null
+++ b/message/pipeline/testdata/test1/locales/en-US/messages.gotext.json
@@ -0,0 +1,91 @@
+{
+    "language": "en-US",
+    "messages": [
+        {
+"id": "Hello world!\n",
+            "key": "Hello world!\n",
+            "message": "Hello world!\n",
+            "translation":  "Hello world!\n"
+        },
+        {
+            "id": "Hello {City}!\n",
+            "key": "Hello %s!\n",
+            "message": "Hello {City}!\n",
+            "translation": "Hello {City}!\n"
+        },
+        {
+            "id": "Hello {Town}!\n",
+            "key": "Hello %s!\n",
+            "message": "Hello {Town}!\n",
+            "translation": "Hello {Town}!\n",
+            "placeholders": [
+                {
+                    "id": "Town",
+                    "string": "%[1]s",
+                    "type": "string",
+                    "underlyingType": "string",
+                    "argNum": 1,
+                    "expr": "town",
+                    "comment": "Town"
+                }
+            ]
+        },
+        {
+            "id": "{Person} is visiting {Place}!\n",
+            "key": "%s is visiting %s!\n",
+            "message": "{Person} is visiting {Place}!\n",
+            "translation": "{Person} is visiting {Place}!\n"
+        },
+        {
+            "id": "{Person} is visiting {Place}!\n",
+            "key": "%[1]s is visiting %[3]s!\n",
+            "message": "{Person} is visiting {Place}!\n",
+            "translation": "{Person} is visiting {Place}!\n"
+        },
+        {
+            "id": "{2} files remaining!",
+            "key": "%d files remaining!",
+            "message": "{N} files remaining!",
+            "translation": "",
+            "placeholders": [
+                {
+                    "id": "2",
+                    "string": "%[1]d"
+                }
+            ]
+        },
+        {
+            "id": "{N} more files remaining!",
+            "key": "%d more files remaining!",
+            "message": "{N} more files remaining!",
+            "translation": {
+                "select": {
+                    "feature": "plural",
+                    "arg": "N",
+                    "cases": {
+                        "one": "One file remaining!",
+                        "other": "There are {N} more files remaining!"
+                    }
+                }
+            }
+        },
+        {
+            "id": "Use the following code for your discount: {ReferralCode}\n",
+            "key": "Use the following code for your discount: %d\n",
+            "message": "Use the following code for your discount: {ReferralCode}\n",
+            "translation": ""
+        },
+        {
+            "id": [ "msgOutOfOrder", "{Device} is out of order!" ],
+            "key": "%s is out of order!",
+            "message": "{Device} is out of order!",
+            "translation": "{Device} is out of order!"
+        },
+        {
+            "id": "{Miles} miles traveled ({Miles_1})",
+            "key": "%.2[1]f miles traveled (%[1]f)",
+            "message": "{Miles} miles traveled ({Miles_1})",
+            "translation": "{Miles} miles traveled ({Miles_1})"
+        }
+    ]
+}
diff --git a/message/pipeline/testdata/test1/locales/zh/messages.gotext.json b/message/pipeline/testdata/test1/locales/zh/messages.gotext.json
new file mode 100755
index 0000000..557a3d2
--- /dev/null
+++ b/message/pipeline/testdata/test1/locales/zh/messages.gotext.json
@@ -0,0 +1,135 @@
+{
+    "language": "zh",
+    "messages": [
+        {
+            "id": "Hello world!\n",
+            "key": "Hello world!\n",
+            "message": "Hello world!\n",
+            "translation": ""
+        },
+        {
+            "id": "Hello {City}!\n",
+            "key": "Hello %s!\n",
+            "message": "Hello {City}!\n",
+            "translation": "",
+            "placeholders": [
+                {
+                    "id": "City",
+                    "string": "%[1]s"
+                }
+            ]
+        },
+        {
+            "id": "Hello {Town}!\n",
+            "key": "Hello %s!\n",
+            "message": "Hello {Town}!\n",
+            "translation": "",
+            "placeholders": [
+                {
+                    "id": "Town",
+                    "string": "%[1]s"
+                }
+            ]
+        },
+        {
+            "id": "{Person} is visiting {Place}!\n",
+            "key": "%s is visiting %s!\n",
+            "message": "{Person} is visiting {Place}!\n",
+            "translation": "",
+            "placeholders": [
+                {
+                    "id": "Person",
+                    "string": "%[1]s"
+                },
+                {
+                    "id": "Place",
+                    "string": "%[2]s"
+                }
+            ]
+        },
+        {
+            "id": "{Person} is visiting {Place}!\n",
+            "key": "%[1]s is visiting %[3]s!\n",
+            "message": "{Person} is visiting {Place}!\n",
+            "translation": "",
+            "placeholders": [
+                {
+                    "id": "Person",
+                    "string": "%[1]s"
+                },
+                {
+                    "id": "Place",
+                    "string": "%[3]s"
+                },
+                {
+                    "id": "Extra",
+                    "string": "%[2]v"
+                }
+            ]
+        },
+        {
+            "id": "{2} files remaining!",
+            "key": "%d files remaining!",
+            "message": "{2} files remaining!",
+            "translation": "",
+            "placeholders": [
+                {
+                    "id": "",
+                    "string": "%[1]d"
+                }
+            ]
+        },
+        {
+            "id": "{N} more files remaining!",
+            "key": "%d more files remaining!",
+            "message": "{N} more files remaining!",
+            "translation": "",
+            "placeholders": [
+                {
+                    "id": "N",
+                    "string": "%[1]d"
+                }
+            ]
+        },
+        {
+            "id": "Use the following code for your discount: {ReferralCode}\n",
+            "key": "Use the following code for your discount: %d\n",
+            "message": "Use the following code for your discount: {ReferralCode}\n",
+            "translation": "",
+            "placeholders": [
+                {
+                    "id": "ReferralCode",
+                    "string": "%[1]d"
+                }
+            ]
+        },
+        {
+            "id": [ "{Device} is out of order!", "msgOutOfOrder" ],
+            "key": "%s is out of order!",
+            "message": "{Device} is out of order!",
+            "translation": "",
+            "placeholders": [
+                {
+                    "id": "Device",
+                    "string": "%[1]s"
+                }
+            ]
+        },
+        {
+            "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"
+                },
+                {
+                    "id": "Miles_1",
+                    "string": "%[1]f"
+                }
+            ]
+        }
+    ]
+}
\ No newline at end of file
diff --git a/message/pipeline/testdata/test1/test1.go b/message/pipeline/testdata/test1/test1.go
new file mode 100644
index 0000000..88051f9
--- /dev/null
+++ b/message/pipeline/testdata/test1/test1.go
@@ -0,0 +1,75 @@
+// 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 "golang.org/x/text/message"
+
+func main() {
+	p := message.NewPrinter(message.MatchLanguage("en"))
+
+	// NOT EXTRACTED: strings passed to Println are not extracted.
+	p.Println("Hello world!")
+
+	// NOT EXTRACTED: strings passed to Print are not extracted.
+	p.Print("Hello world!\n")
+
+	// Extract and trim whitespace (TODO).
+	p.Printf("Hello world!\n")
+
+	// NOT EXTRACTED: city is not used as a pattern or passed to %m.
+	city := "Amsterdam"
+	// This comment is extracted.
+	p.Printf("Hello %s!\n", city)
+
+	person := "Sheila"
+	place := "Zürich"
+
+	// Substitutions replaced by variable names.
+	p.Printf("%s is visiting %s!\n",
+		person, // The person of matter.
+		place,  // Place the person is visiting.
+	)
+
+	pp := struct {
+		Person string // The person of matter. // TODO: get this comment.
+		Place  string
+		extra  int
+	}{
+		person, place, 4,
+	}
+
+	// extract will drop this comment in favor of the one below.
+	p.Printf("%[1]s is visiting %[3]s!\n", // Field names are placeholders.
+		pp.Person,
+		pp.extra,
+		pp.Place, // Place the person is visiting.
+	)
+
+	// Numeric literal becomes placeholder.
+	p.Printf("%d files remaining!", 2)
+
+	const n = 2
+
+	// Constant identifier becomes placeholder.
+	p.Printf("%d more files remaining!", n)
+
+	// Infer better names from type names.
+	type referralCode int
+
+	const c = referralCode(5)
+
+	// Use type name as placeholder.
+	p.Printf("Use the following code for your discount: %d\n", c)
+
+	// Use constant name as message ID.
+	const msgOutOfOrder = "%s is out of order!" // This comment wins.
+	const device = "Soda machine"
+	// This message has two IDs.
+	p.Printf(msgOutOfOrder, device)
+
+	// Multiple substitutions for same argument.
+	miles := 1.2345
+	p.Printf("%.2[1]f miles traveled (%[1]f)", miles)
+}