diff --git a/cmd/xgettext-go/main.go b/cmd/xgettext-go/main.go new file mode 100644 index 0000000..6485133 --- /dev/null +++ b/cmd/xgettext-go/main.go @@ -0,0 +1,107 @@ +package main + +import ( + "bytes" + "io/ioutil" + "log" + "os" + "strings" + "time" + + "github.com/jessevdk/go-flags" + + "github.com/snapcore/go-gettext/internal/xgettext" +) + +var formatTime = func() string { + return time.Now().Format("2006-01-02 15:04-0700") +} + +type options struct { + FilesFrom string `short:"f" long:"files-from" value-name:"FILE" description:"get list of input files from FILE"` + + Directories []string `short:"D" long:"directory" value-name:"DIRECTORY" description:"add DIRECTORY to list for input files search"` + + Output string `short:"o" long:"output" value-name:"FILE" description:"output to specified file"` + + CommentTags []string `short:"c" long:"add-comments" optional:"true" optional-value:"" value-name:"TAG" description:"place all comment blocks preceding keyword lines in output file"` + + Keywords []string `short:"k" long:"keyword" optional:"true" optional-value:"" value-name:"WORD" description:"look for WORD as the keyword for singular strings"` + + NoLocation bool `long:"no-location" description:"do not write '#: filename:line' lines"` + + SortOutput bool `short:"s" long:"sort-output" description:"generate sorted output"` + + PackageName string `long:"package-name" value-name:"PACKAGE" description:"set package name in output"` + + MsgidBugsAddress string `long:"msgid-bugs-address" default:"EMAIL" value-name:"ADDRESS" description:"set report address for msgid bugs"` +} + +func main() { + // parse args + var opts options + args, err := flags.ParseArgs(&opts, os.Args) + if err != nil { + if flagsErr, ok := err.(*flags.Error); ok && flagsErr.Type == flags.ErrHelp { + os.Exit(0) + } + os.Exit(1) + } + + var files []string + if opts.FilesFrom != "" { + content, err := ioutil.ReadFile(opts.FilesFrom) + if err != nil { + log.Fatalf("cannot read file %v: %v", opts.FilesFrom, err) + } + content = bytes.TrimSpace(content) + files = strings.Split(string(content), "\n") + } else { + files = args[1:] + } + + extractor := xgettext.Extractor{ + Directories: opts.Directories, + CommentTags: opts.CommentTags, + SortOutput: opts.SortOutput, + NoLocation: opts.NoLocation, + PackageName: opts.PackageName, + MsgidBugsAddress: opts.MsgidBugsAddress, + CreationDate: formatTime(), + } + log.Printf("keywords: %#v", opts.Keywords) + addDefaultKeywords := true + for _, spec := range opts.Keywords { + if spec == "" { + // a bare "-k" option disables the default keywords + addDefaultKeywords = false + continue + } + kw, err := xgettext.ParseKeyword(spec) + if err != nil { + log.Fatalf("cannot parse keyword %s: %s", spec, err) + } + extractor.Keywords = append(extractor.Keywords, kw) + } + if addDefaultKeywords { + extractor.AddDefaultKeywords() + } + + for _, filename := range files { + if err := extractor.ParseFile(filename); err != nil { + log.Fatalf("cannot parse file %s: %s", filename, err) + } + } + + out := os.Stdout + if opts.Output != "" { + var err error + out, err = os.Create(opts.Output) + if err != nil { + log.Fatalf("failed to create %s: %s", opts.Output, err) + } + } + if err := extractor.Write(out); err != nil { + log.Fatalf("failed to write po template: %s", err) + } +} diff --git a/go.mod b/go.mod index 7815e79..be1242b 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,14 @@ module github.com/snapcore/go-gettext go 1.18 + +require ( + github.com/jessevdk/go-flags v1.4.0 + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c +) + +require ( + github.com/kr/pretty v0.3.1 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/rogpeppe/go-internal v1.12.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..e1e80f1 --- /dev/null +++ b/go.sum @@ -0,0 +1,16 @@ +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/jessevdk/go-flags v1.4.0 h1:4IU2WS7AumrZ/40jfhf4QVDMsQwqA7VEHozFRrGARJA= +github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= +github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/internal/xgettext/xgettext.go b/internal/xgettext/xgettext.go new file mode 100644 index 0000000..c431d9a --- /dev/null +++ b/internal/xgettext/xgettext.go @@ -0,0 +1,469 @@ +package xgettext + +import ( + "errors" + "fmt" + "go/ast" + "go/parser" + "go/token" + "io" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "text/template" +) + +var ( + ErrNotString = errors.New("not a string constant") + ErrBadKeyword = errors.New("bad keyword") + ErrOutOfRange = errors.New("argument index out of range") +) + +// stringConstant evaluates an ast.Expr representing a string constant +// +// In addition to +func stringConstant(expr ast.Expr) (string, error) { + switch val := expr.(type) { + case *ast.BasicLit: + if val.Kind != token.STRING { + return "", ErrNotString + } + s, err := strconv.Unquote(val.Value) + if err != nil { + return "", err + } + return s, nil + // Support simple string concatenation + case *ast.BinaryExpr: + // we only support string concat + if val.Op != token.ADD { + return "", ErrNotString + } + left, err := stringConstant(val.X) + if err != nil { + return "", err + } + right, err := stringConstant(val.Y) + if err != nil { + return "", err + } + return left + right, nil + // Support parenthesised expressions + case *ast.ParenExpr: + return stringConstant(val.X) + } + return "", ErrNotString +} + +type Keyword struct { + name, pkg string + msgid, msgidPlural, msgContext int +} + +func ParseKeyword(spec string) (*Keyword, error) { + // Keyword spec is of form [PKG.]FUNC[:ARG,...] + idx := strings.IndexByte(spec, ':') + var function, pkg string + var args []string + if idx >= 0 { + function = spec[:idx] + args = strings.Split(spec[idx+1:], ",") + } else { + function = spec + } + + idx = strings.IndexByte(function, '.') + if idx >= 0 { + pkg = function[:idx] + function = function[idx+1:] + if strings.IndexByte(function, '.') >= 0 { + return nil, ErrBadKeyword + } + } + + k := &Keyword{ + name: function, + pkg: pkg, + msgid: 0, + msgidPlural: -1, + msgContext: -1, + } + + // Now process arguments + processed := 0 + for _, arg := range args { + if arg[len(arg)-1] == 'c' { + // This is the context + val, err := strconv.Atoi(arg[:len(arg)-1]) + if err != nil { + return nil, err + } + k.msgContext = val - 1 + continue + } + + val, err := strconv.Atoi(arg) + if err != nil { + return nil, err + } + switch processed { + case 0: + k.msgid = val - 1 + case 1: + k.msgidPlural = val - 1 + default: + return nil, ErrBadKeyword + } + processed += 1 + } + + return k, nil +} + +func (k *Keyword) Match(call *ast.CallExpr) bool { + var pkg, name string + + switch e := call.Fun.(type) { + case *ast.Ident: + name = e.Name + case *ast.SelectorExpr: + name = e.Sel.Name + if ident, ok := e.X.(*ast.Ident); ok { + pkg = ident.Name + } + default: + return false + } + + if name != k.name { + return false + } + // If the keyword includes a package qualifier, make sure it matches + return k.pkg == "" || k.pkg == pkg +} + +func (k *Keyword) Extract(call *ast.CallExpr) (msg Message, err error) { + if k.msgid >= len(call.Args) { + return Message{}, ErrOutOfRange + } + msg.msgid, err = stringConstant(call.Args[k.msgid]) + if err != nil { + return Message{}, err + } + if k.msgidPlural >= 0 { + if k.msgidPlural >= len(call.Args) { + return Message{}, ErrOutOfRange + } + msg.msgidPlural, err = stringConstant(call.Args[k.msgidPlural]) + if err != nil { + return Message{}, err + } + } + if k.msgContext >= 0 { + if k.msgContext >= len(call.Args) { + return Message{}, ErrOutOfRange + } + msg.msgContext, err = stringConstant(call.Args[k.msgContext]) + if err != nil { + return Message{}, err + } + } + return msg, nil +} + +type Message struct { + msgid string + msgidPlural string + msgContext string +} + +func (m *Message) Less(other *Message) bool { + if m.msgid != other.msgid { + return m.msgid < other.msgid + } + if m.msgidPlural != other.msgidPlural { + return m.msgidPlural < other.msgidPlural + } + return m.msgContext < other.msgContext +} + +type Location struct { + file string + line int + comments string + format bool +} + +type visitor struct { + *Extractor + + fset *token.FileSet + file *ast.File +} + +func commentGroupContent(cg *ast.CommentGroup) string { + var lines []string + for _, comment := range cg.List { + for _, line := range strings.Split(comment.Text, "\n") { + line = strings.TrimPrefix(line, "//") + line = strings.TrimPrefix(line, "/*") + line = strings.TrimSuffix(line, "*/") + line = strings.TrimSpace(line) + if line != "" { + lines = append(lines, "#. "+line+"\n") + } + } + } + return strings.Join(lines, "") +} + +func (v *visitor) findCommentsBefore(pos token.Position) string { + for i := len(v.file.Comments) - 1; i >= 0; i-- { + cg := v.file.Comments[i] + cgPos := v.fset.Position(cg.End()) + if cgPos.Line+1 == pos.Line { + return commentGroupContent(cg) + } + } + return "" +} + +func (v *visitor) Visit(node ast.Node) ast.Visitor { + // We're only interested in calls + call, ok := node.(*ast.CallExpr) + if !ok { + return v + } + + for _, k := range v.Keywords { + if !k.Match(call) { + continue + } + + msg, err := k.Extract(call) + if err != nil { + break + } + + pos := v.fset.Position(node.Pos()) + var comments string + if len(v.CommentTags) != 0 { + comments = v.findCommentsBefore(pos) + keep := false + for _, tag := range v.CommentTags { + if strings.HasPrefix(comments, "#. "+tag) { + keep = true + break + } + } + if !keep { + comments = "" + } + } + + v.Messages[msg] = append(v.Messages[msg], Location{ + file: pos.Filename, + line: pos.Line, + comments: comments, + // FIXME: too simplistic, should check if call + // used as a format argument. + format: strings.IndexByte(msg.msgid, '%') >= 0, + }) + break + } + return v +} + +type Extractor struct { + Messages map[Message][]Location + Keywords []*Keyword + CommentTags []string + Directories []string + SortOutput bool + NoLocation bool + + PackageName string + MsgidBugsAddress string + CreationDate string +} + +func (e *Extractor) AddDefaultKeywords() { + for _, spec := range []string{ + "Gettext:1", + "NGettext:1,2", + "PGettext:1c,2", + "NPGettext:1c,2,3", + } { + kw, err := ParseKeyword(spec) + if err != nil { + panic(err) + } + e.Keywords = append(e.Keywords, kw) + } +} + +func (e *Extractor) openFile(filename string) (f *os.File, err error) { + if len(e.Directories) == 0 || filepath.IsAbs(filename) { + return os.Open(filename) + } + for _, dir := range e.Directories { + f, err = os.Open(filepath.Join(dir, filename)) + if !os.IsNotExist(err) { + break + } + } + return f, err +} + +func (e *Extractor) parseStream(filename string, r io.Reader) (err error) { + var v visitor + v.Extractor = e + v.fset = token.NewFileSet() + v.file, err = parser.ParseFile(v.fset, filename, r, parser.ParseComments) + if err != nil { + return err + } + + if e.Messages == nil { + e.Messages = make(map[Message][]Location) + } + ast.Walk(&v, v.file) + return nil +} + +func (e *Extractor) ParseFile(filename string) error { + f, err := e.openFile(filename) + if err != nil { + return err + } + defer f.Close() + return e.parseStream(filename, f) +} + +const poTemplateData = `# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: {{ or .Extractor.PackageName "PACKAGE" }}\n" +{{ if .Extractor.MsgidBugsAddress -}} +"Report-Msgid-Bugs-To: {{ .Extractor.MsgidBugsAddress }}\n" +{{ end -}} +"POT-Creation-Date: {{ .Extractor.CreationDate }}\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=CHARSET\n" +"Content-Transfer-Encoding: 8bit\n" +{{ range .Messages -}} +{{ "\n" -}} +{{ .Comments -}} +{{ .Positions -}} +{{ if .Format -}} +#, c-format +{{ end -}} +{{ if .MsgContext -}} +msgctxt {{ .MsgContext }} +{{ end -}} +msgid {{ .Msgid }} +{{ if .MsgidPlural -}} +msgid_plural {{ .MsgidPlural }} +{{ end -}} +{{ if .MsgidPlural -}} +msgstr[0] "" +msgstr[1] "" +{{ else -}} +msgstr "" +{{ end -}} +{{end -}} +` + +var poTemplate = template.Must(template.New("po").Parse(poTemplateData)) + +type messageData struct { + msg Message + Msgid string + MsgidPlural string + MsgContext string + + Positions string + Comments string + Format bool +} + +func quoteMsgid(msg string) string { + if len(msg) == 0 { + return "" + } + + quoted := []string{`""`} + for _, line := range strings.SplitAfter(msg, "\n") { + if len(line) == 0 { + continue + } + quoted = append(quoted, strconv.Quote(line)) + } + + if len(quoted) == 2 { + return quoted[1] + } + return strings.Join(quoted, "\n") +} + +func (e *Extractor) Write(w io.Writer) error { + msgData := make([]*messageData, 0, len(e.Messages)) + for msg, locs := range e.Messages { + data := &messageData{msg: msg} + msgData = append(msgData, data) + data.Msgid = quoteMsgid(msg.msgid) + data.MsgidPlural = quoteMsgid(msg.msgidPlural) + data.MsgContext = quoteMsgid(msg.msgContext) + if e.SortOutput { + sort.Slice(locs, func(i, j int) bool { + return locs[i].file < locs[j].file || locs[i].file == locs[j].file && locs[i].line < locs[j].line + }) + } + var positions string + for _, loc := range locs { + data.Comments += loc.comments + if loc.format { + data.Format = true + } + + if e.NoLocation { + continue + } + pos := fmt.Sprintf("%s:%d", loc.file, loc.line) + if len(positions)+len(pos) > 75 { + data.Positions += "#:" + positions + "\n" + positions = "" + } + positions += " " + pos + } + if len(positions) > 0 { + data.Positions += "#:" + positions + "\n" + } + } + + if e.SortOutput { + sort.Slice(msgData, func(i, j int) bool { + return msgData[i].msg.Less(&msgData[j].msg) + }) + } + + return poTemplate.Execute(w, struct { + Extractor *Extractor + Messages []*messageData + }{ + Extractor: e, + Messages: msgData, + }) + return nil +} diff --git a/internal/xgettext/xgettext_test.go b/internal/xgettext/xgettext_test.go new file mode 100644 index 0000000..7e782cb --- /dev/null +++ b/internal/xgettext/xgettext_test.go @@ -0,0 +1,273 @@ +package xgettext + +import ( + "bytes" + "go/ast" + "go/parser" + "testing" + + . "gopkg.in/check.v1" +) + +func Test(t *testing.T) { + TestingT(t) +} + +var _ = Suite(xgettextSuite{}) + +type xgettextSuite struct{} + +func (xgettextSuite) TestStringConstant(c *C) { + for _, test := range []struct { + code, expected string + }{ + {`"Hello world"`, "Hello world"}, + {"`Hello world`", "Hello world"}, + {"\"Hello \" + `world`", "Hello world"}, + {`"Line 1\nLine 2"`, "Line 1\nLine 2"}, + {"`Line 1\\nLine 1`", "Line 1\\nLine 1"}, + {`("Hello")`, "Hello"}, + {`("a"+"b")+("c"+"d")`, "abcd"}, + } { + comment := Commentf("expression: %s", test.code) + expr, err := parser.ParseExpr(test.code) + c.Assert(err, IsNil, comment) + result, err := stringConstant(expr) + if !c.Check(err, IsNil, comment) { + continue + } + c.Check(result, Equals, test.expected, comment) + } + + for _, code := range []string{ + "1", + "'x'", + "`xyz`+2", + `("a"+"b")+("c"+42)`, + } { + expr, err := parser.ParseExpr(code) + c.Assert(err, IsNil) + result, err := stringConstant(expr) + c.Check(err, NotNil, Commentf("expression %s evaluated to %q", code, result)) + } +} + +func (xgettextSuite) TestParseKeyword(c *C) { + for _, test := range []struct { + spec string + kw Keyword + }{ + {"Gettext", Keyword{"Gettext", "", 0, -1, -1}}, + {"NGettext:1,2", Keyword{"NGettext", "", 0, 1, -1}}, + {"PGettext:1c,2", Keyword{"PGettext", "", 1, -1, 0}}, + {"PGettext:2,1c", Keyword{"PGettext", "", 1, -1, 0}}, + {"NPGettext:1c,2,3", Keyword{"NPGettext", "", 1, 2, 0}}, + {"NPGettext:2,3,1c", Keyword{"NPGettext", "", 1, 2, 0}}, + {"NPGettext:2,1c,3", Keyword{"NPGettext", "", 1, 2, 0}}, + {"i18n.G", Keyword{"G", "i18n", 0, -1, -1}}, + {"i18n.NG:1,2", Keyword{"NG", "i18n", 0, 1, -1}}, + } { + comment := Commentf("keyword spec: %s", test.spec) + kw, err := ParseKeyword(test.spec) + if !c.Check(err, IsNil, comment) { + continue + } + c.Check(*kw, Equals, test.kw, comment) + } + + for _, spec := range []string{ + "foo:1,2,3", + "bar:1c,2,3,4", + "foo:bar", + "foo:50x,2", + } { + kw, err := ParseKeyword(spec) + c.Check(err, NotNil, Commentf("spec %s evaluated to %#v", spec, kw)) + } +} + +func (xgettextSuite) TestKeywordMatch(c *C) { + for _, test := range []struct { + spec string + code string + ok bool + }{ + {"Gettext", "Gettext()", true}, + {"Gettext", "foo.Gettext()", true}, + {"Gettext", "foo.bar.Gettext()", true}, + {"Gettext", "NotGettext()", false}, + {"i18n.G", "G()", false}, + {"i18n.G", "i18n.G()", true}, + {"i18n.G", "foo.i18n.G()", false}, + } { + comment := Commentf("spec: %s, expr: %s", test.spec, test.code) + kw, err := ParseKeyword(test.spec) + c.Assert(err, IsNil, comment) + expr, err := parser.ParseExpr(test.code) + c.Assert(err, IsNil, comment) + + c.Check(kw.Match(expr.(*ast.CallExpr)), Equals, test.ok, comment) + } +} + +func (xgettextSuite) TestKeywordExtract(c *C) { + for _, test := range []struct { + spec string + code string + ok bool + msg Message + }{ + {"Gettext", `Gettext("foo\tbar")`, true, Message{msgid: "foo\tbar"}}, + {"Gettext", `Gettext(foo())`, false, Message{}}, + {"NGettext:1,2", `NGettext("foo", "bar", n)`, true, Message{msgid: "foo", msgidPlural: "bar"}}, + {"NGettext:1,2", `NGettext(foo(), "bar", n)`, false, Message{}}, + {"NGettext:1,2", `NGettext("foo", bar(), n)`, false, Message{}}, + {"PGettext:1c,2", `PGettext("foo", "bar")`, true, Message{msgid: "bar", msgContext: "foo"}}, + {"NPGettext:1c,2,3", `NPGettext("foo", "bar", "baz", n)`, true, Message{msgid: "bar", msgidPlural: "baz", msgContext: "foo"}}, + {"NPGettext:1c,2,3", `NPGettext(foo(), "bar", "baz", n)`, false, Message{}}, + {"NPGettext:1c,2,3", `NPGettext("foo", bar(), "baz", n)`, false, Message{}}, + {"NPGettext:1c,2,3", `NPGettext("foo", "bar", baz(), n)`, false, Message{}}, + + // out of bounds argument index + {"Gettext:1", `Gettext()`, false, Message{}}, + {"NGettext:1,2", `NGettext("foo")`, false, Message{}}, + {"PGettext:1,2c", `PGettext("foo")`, false, Message{}}, + } { + comment := Commentf("spec: %s, expr: %s", test.spec, test.code) + kw, err := ParseKeyword(test.spec) + c.Assert(err, IsNil, comment) + expr, err := parser.ParseExpr(test.code) + c.Assert(err, IsNil, comment) + + msg, err := kw.Extract(expr.(*ast.CallExpr)) + c.Check(err == nil, Equals, test.ok, comment) + c.Check(msg, Equals, test.msg, comment) + } +} + +func (xgettextSuite) TestExtractorParseStream(c *C) { + const fooContent = `package main + +func foo() { + println(Gettext("msg")) + println(PGettext("context1", "msg")) + // Not a translator comment + println(NGettext("single %d", "plural %d", 0)) +} +` + const barContent = `package main + +func bar() { + // TRANS: bar + println(PGettext("context2", "msg")) + // TRANSLATORS: xyz + println(Gettext("msg")) +} +` + + var e Extractor + e.AddDefaultKeywords() + e.CommentTags = append(e.CommentTags, "TRANSLATORS:", "TRANS:") + err := e.parseStream("foo.go", bytes.NewReader([]byte(fooContent))) + c.Assert(err, IsNil) + err = e.parseStream("bar.go", bytes.NewReader([]byte(barContent))) + c.Assert(err, IsNil) + + c.Check(e.Messages, DeepEquals, map[Message][]Location{ + {"msg", "", ""}: { + {"foo.go", 4, "", false}, + {"bar.go", 7, "#. TRANSLATORS: xyz\n", false}, + }, + {"msg", "", "context1"}: { + {"foo.go", 5, "", false}, + }, + {"msg", "", "context2"}: { + {"bar.go", 5, "#. TRANS: bar\n", false}, + }, + {"single %d", "plural %d", ""}: { + {"foo.go", 7, "", true}, + }, + }) +} + +func (xgettextSuite) TestExtractorWrite(c *C) { + e := Extractor{ + SortOutput: true, + PackageName: "testing", + MsgidBugsAddress: "bugs@example.org", + CreationDate: "1970-01-01 TT:TT+00:00", + Messages: map[Message][]Location{ + {"one line", "", ""}: { + {"foo.go", 4, "#. comment foo\n", false}, + {"bar.go", 42, "#. comment bar\n", true}, + }, + {"two\nlines", "", ""}: { + {"file.go", 100, "", false}, + }, + {"single", "plural", ""}: { + {"file.go", 10, "#. xyz\n", false}, + }, + {"foo", "", "context"}: { + {"file.go", 50, "", false}, + }, + {"hello\tworld", "", ""}: { + {"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.go", 10, "", false}, + {"yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy.go", 20, "", false}, + }, + }, + } + + var buffer bytes.Buffer + c.Assert(e.Write(&buffer), IsNil) + + const expectedPot = `# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: testing\n" +"Report-Msgid-Bugs-To: bugs@example.org\n" +"POT-Creation-Date: 1970-01-01 TT:TT+00:00\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=CHARSET\n" +"Content-Transfer-Encoding: 8bit\n" + +#: file.go:50 +msgctxt "context" +msgid "foo" +msgstr "" + +#: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.go:10 +#: yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy.go:20 +msgid "hello\tworld" +msgstr "" + +#. comment bar +#. comment foo +#: bar.go:42 foo.go:4 +#, c-format +msgid "one line" +msgstr "" + +#. xyz +#: file.go:10 +msgid "single" +msgid_plural "plural" +msgstr[0] "" +msgstr[1] "" + +#: file.go:100 +msgid "" +"two\n" +"lines" +msgstr "" +` + c.Check(buffer.String(), Equals, expectedPot) +}