package main import ( "bytes" "fmt" "go/ast" "go/parser" "go/token" "io" "log" "os" "path/filepath" "sort" "strings" "time" "github.com/jessevdk/go-flags" ) type msgID struct { msgidPlural string comment string context string fname string line int formatHint string } var msgIDs map[string][]msgID func formatComment(com string) string { out := "" for _, rawline := range strings.Split(com, "\n") { line := rawline line = strings.TrimPrefix(line, "//") line = strings.TrimPrefix(line, "/*") line = strings.TrimSuffix(line, "*/") line = strings.TrimSpace(line) if line != "" { out += fmt.Sprintf("#. %s\n", line) } } return out } func findCommentsForTranslation(fset *token.FileSet, f *ast.File, posCall token.Position) string { com := "" for _, cg := range f.Comments { // search for all comments in the previous line for i := len(cg.List) - 1; i >= 0; i-- { c := cg.List[i] posComment := fset.Position(c.End()) //println(posCall.Line, posComment.Line, c.Text) if posCall.Line == posComment.Line+1 { posCall = posComment com = fmt.Sprintf("%s\n%s", c.Text, com) } } } // only return if we have a matching prefix formatedComment := formatComment(com) needle := fmt.Sprintf("#. %s", opts.AddCommentsTag) if !strings.HasPrefix(formatedComment, needle) { formatedComment = "" } return formatedComment } func constructValue(val any) string { switch val.(type) { case *ast.BasicLit: return val.(*ast.BasicLit).Value // this happens for constructs like: // gettext.Gettext("foo" + "bar") case *ast.BinaryExpr: // we only support string concat if val.(*ast.BinaryExpr).Op != token.ADD { return "" } left := constructValue(val.(*ast.BinaryExpr).X) // strip right " (or `) left = left[0 : len(left)-1] right := constructValue(val.(*ast.BinaryExpr).Y) // strip left " (or `) right = right[1:] return left + right default: panic(fmt.Sprintf("unknown type: %v", val)) } } func inspectNodeForTranslations(fset *token.FileSet, f *ast.File, n ast.Node) bool { // FIXME: this assume we always have a "gettext.Gettext" style keyword l := strings.Split(opts.Keyword, ".") gettextSelector := l[0] gettextFuncName := l[1] l = strings.Split(opts.KeywordPlural, ".") gettextSelectorPlural := l[0] gettextFuncNamePlural := l[1] l = strings.Split(opts.KeywordContext, ".") gettextSelectorContext := l[0] gettextFuncNameContext := l[1] switch x := n.(type) { case *ast.CallExpr: if sel, ok := x.Fun.(*ast.SelectorExpr); ok { i18nStr := "" i18nStrPlural := "" i18nStrContext := "" if sel.Sel.Name == gettextFuncNamePlural && sel.X.(*ast.Ident).Name == gettextSelectorPlural { i18nStr = x.Args[0].(*ast.BasicLit).Value i18nStrPlural = x.Args[1].(*ast.BasicLit).Value } if sel.Sel.Name == gettextFuncNameContext && sel.X.(*ast.Ident).Name == gettextSelectorContext { i18nStr = x.Args[0].(*ast.BasicLit).Value i18nStrContext = x.Args[1].(*ast.BasicLit).Value } if sel.Sel.Name == gettextFuncName && sel.X.(*ast.Ident).Name == gettextSelector { i18nStr = constructValue(x.Args[0]) } formatI18nStr := func(s string) string { if s == "" { return "" } // the "`" is special if s[0] == '`' { // keep escaped ", replace inner " with \", replace \n with \\n rep := strings.NewReplacer(`\"`, `\"`, `"`, `\"`, "\n", "\\n") s = rep.Replace(s) } // strip leading and trailing " (or `) s = s[1 : len(s)-1] return s } // FIXME: too simplistic(?), no %% is considered formatHint := "" if strings.Contains(i18nStr, "%") || strings.Contains(i18nStrPlural, "%") { // well, not quite correct but close enough formatHint = "c-format" } if i18nStr != "" { msgidStr := formatI18nStr(i18nStr) posCall := fset.Position(n.Pos()) msgIDs[msgidStr] = append(msgIDs[msgidStr], msgID{ formatHint: formatHint, msgidPlural: formatI18nStr(i18nStrPlural), context: formatI18nStr(i18nStrContext), fname: posCall.Filename, line: posCall.Line, comment: findCommentsForTranslation(fset, f, posCall), }) } } } return true } func processFiles(args []string) error { // go over the input files msgIDs = make(map[string][]msgID) fset := token.NewFileSet() for _, fname := range args { if err := processSingleGoSource(fset, fname); err != nil { return err } } return nil } func readContent(fname string) (content []byte, err error) { // If no search directories have been specified or we have an // absolute path, just try to read the contents directly. if len(opts.Directories) == 0 || filepath.IsAbs(fname) { return os.ReadFile(fname) } // Otherwise, search for the file in each of the configured // directories. for _, dir := range opts.Directories { content, err = os.ReadFile(filepath.Join(dir, fname)) if !os.IsNotExist(err) { break } } return content, err } func processSingleGoSource(fset *token.FileSet, fname string) error { fnameContent, err := readContent(fname) if err != nil { return err } // Create the AST by parsing src. f, err := parser.ParseFile(fset, fname, fnameContent, parser.ParseComments) if err != nil { return err } ast.Inspect(f, func(n ast.Node) bool { return inspectNodeForTranslations(fset, f, n) }) return nil } var formatTime = func() string { return time.Now().Format("2006-01-02 15:04-0700") } // mustFprintf will write the given format string to the given // writer. Any error will make it panic. func mustFprintf(w io.Writer, format string, a ...any) { _, err := fmt.Fprintf(w, format, a...) if err != nil { panic(fmt.Sprintf("cannot write output: %v", err)) } } func formatOutput(in string) string { // split string with \n into multiple lines // to make the output nicer out := strings.Replace(in, "\\n", "\\n\"\n \"", -1) // cleanup too aggressive splitting (empty "" lines) return strings.TrimSuffix(out, "\"\n \"") } func writeMsg(out io.Writer, k string, msgidList []msgID) { for _, msgid := range msgidList { if opts.AddComments || opts.AddCommentsTag != "" { mustFprintf(out, "%s", msgid.comment) } } if !opts.NoLocation { mustFprintf(out, "#:") for _, msgid := range msgidList { mustFprintf(out, " %s:%d", msgid.fname, msgid.line) } mustFprintf(out, "\n") } msgid := msgidList[0] if msgid.formatHint != "" { mustFprintf(out, "#, %s\n", msgid.formatHint) } mustFprintf(out, "msgid \"%v\"\n", formatOutput(k)) if msgid.msgidPlural != "" { mustFprintf(out, "msgid_plural \"%v\"\n", formatOutput(msgid.msgidPlural)) mustFprintf(out, "msgstr[0] \"\"\n") mustFprintf(out, "msgstr[1] \"\"\n") } else { mustFprintf(out, "msgstr \"\"\n") } mustFprintf(out, "\n") } func writeMsgWithContext(out io.Writer, k string, msgidList []msgID) { for _, msgid := range msgidList { if opts.AddComments || opts.AddCommentsTag != "" { mustFprintf(out, "%s", msgid.comment) } if !opts.NoLocation { mustFprintf(out, "#: %s:%d\n", msgid.fname, msgid.line) } if msgid.formatHint != "" { mustFprintf(out, "#, %s\n", msgid.formatHint) } if msgid.context != "" { mustFprintf(out, "msgctx \"%v\"\n", formatOutput(msgid.context)) } mustFprintf(out, "msgid \"%v\"\n", formatOutput(k)) if msgid.msgidPlural != "" { mustFprintf(out, "msgid_plural \"%v\"\n", formatOutput(msgid.msgidPlural)) mustFprintf(out, "msgstr[0] \"\"\n") mustFprintf(out, "msgstr[1] \"\"\n") } else { mustFprintf(out, "msgstr \"\"\n") } mustFprintf(out, "\n") } } func writePotFile(out io.Writer) { header := fmt.Sprintf(`# 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: %s\n" "Report-Msgid-Bugs-To: %s\n" "POT-Creation-Date: %s\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" `, opts.PackageName, opts.MsgIDBugsAddress, formatTime()) mustFprintf(out, "%s", header) // yes, this is the way to do it in go sortedKeys := []string{} for k := range msgIDs { sortedKeys = append(sortedKeys, k) } if opts.SortOutput { sort.Strings(sortedKeys) } // FIXME: use template here? for _, k := range sortedKeys { var msgIDsWithoutContext []msgID var msgIDsWithContext []msgID msgidList := msgIDs[k] for _, msgid := range msgidList { if msgid.context == "" { msgIDsWithoutContext = append(msgIDsWithoutContext, msgid) } else { msgIDsWithContext = append(msgIDsWithContext, msgid) } } if len(msgIDsWithoutContext) > 0 { writeMsg(out, k, msgIDsWithoutContext) } if len(msgIDsWithContext) > 0 { writeMsgWithContext(out, k, msgIDsWithContext) } } } // FIXME: this must be setable via go-flags var opts struct { FilesFrom string `short:"f" long:"files-from" description:"get list of input files from FILE"` Directories []string `short:"D" long:"directory" description:"add DIRECTORY to list for input files search"` Output string `short:"o" long:"output" description:"output to specified file"` AddComments bool `short:"c" long:"add-comments" description:"place all comment blocks preceding keyword lines in output file"` AddCommentsTag string `long:"add-comments-tag" description:"place comment blocks starting with TAG and prceding keyword lines in output file"` SortOutput bool `short:"s" long:"sort-output" description:"generate sorted output"` NoLocation bool `long:"no-location" description:"do not write '#: filename:line' lines"` MsgIDBugsAddress string `long:"msgid-bugs-address" default:"EMAIL" description:"set report address for msgid bugs"` PackageName string `long:"package-name" description:"set package name in output"` Keyword string `short:"k" long:"keyword" default:"gettext.Gettext" description:"look for WORD as the keyword for singular strings"` KeywordContext string `long:"keyword-ctx" default:"gettext.PGettext" description:"look for WORD as the keyword for singular strings with context"` KeywordPlural string `long:"keyword-plural" default:"gettext.NGettext" description:"look for WORD as the keyword for plural strings"` } func main() { // parse args args, err := flags.ParseArgs(&opts, os.Args) if err != nil { log.Fatalf("ParseArgs failed %s", err) } var files []string if opts.FilesFrom != "" { content, err := os.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:] } if err := processFiles(files); err != nil { log.Fatalf("processFiles failed with: %s", 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) } } writePotFile(out) }