Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add command to scan for new translation #5168

Open
wants to merge 39 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
916c93a
WIP: command to scan for new translation
sdassow Sep 27, 2024
2958668
Add flag for dry-run and to update existing translations, clean up an…
sdassow Oct 1, 2024
568611a
Move options into separate structure to cut dependency
sdassow Oct 1, 2024
6951c02
Add simple end to end test
sdassow Oct 1, 2024
435f2e9
Merge branch 'develop' into add-translate-command
sdassow Oct 15, 2024
d3b81b7
Clean up comments and improve documentation based on feedback
sdassow Oct 15, 2024
458d526
Add a few more tests
sdassow Oct 15, 2024
0958357
Add tests for remaining functions
sdassow Oct 15, 2024
ad821b4
Change test values to make them easier to distinguish
sdassow Oct 15, 2024
70950d5
Use github.com/natefinch/atomic to make file renaming on windows work
sdassow Oct 15, 2024
72ba4b5
Write twice to cover non-existing and existing case
sdassow Oct 15, 2024
6593019
Move defer into the corresponding scope
sdassow Oct 15, 2024
6670eb7
Check for more errors and add missing defer in tests
sdassow Oct 15, 2024
6758ed6
Oops, unbreak
sdassow Oct 15, 2024
af2af8b
Add another missing defer
sdassow Oct 15, 2024
5f39d42
Stop changing default file permissions as it affects portability
sdassow Oct 16, 2024
40add49
Close file directly after read
sdassow Oct 16, 2024
73de44f
Close file directly after read
sdassow Oct 16, 2024
928ca30
Ensure tests use the right paths
sdassow Oct 16, 2024
0c26dcd
Oops, chdir made everything weird, so stop doing that
sdassow Oct 16, 2024
65858fe
Make options the last argument as done elsewhere
sdassow Oct 16, 2024
0e9a7ff
Merge branch 'develop' into add-translate-command
sdassow Oct 16, 2024
01011fa
Document version near main entry point for the command
sdassow Oct 18, 2024
2559dd1
Verify file name
sdassow Oct 26, 2024
6c1d1ed
Ignore non-existing files when searching
sdassow Oct 26, 2024
3dc095e
Explicitely create directory in each test function
sdassow Oct 26, 2024
c36b9a9
Add function to handle finding sources with some tests
sdassow Oct 26, 2024
70ee2c3
Remove source directory option and allow files and directories as arg…
sdassow Oct 26, 2024
39acd0f
Switch to four spaces like the current translation tool
sdassow Oct 26, 2024
4d23f0e
Remove translationsFile flag and make it a mandatory argument instead…
sdassow Oct 26, 2024
fbec8d7
Fix test after change
sdassow Oct 26, 2024
8897390
Merge branch 'develop' into add-translate-command
sdassow Oct 26, 2024
efb5574
Show exact result when test fails
sdassow Oct 27, 2024
525d866
Use filepath to be more portable
sdassow Oct 27, 2024
5af73a8
Make sure the first argument has the right file extension to prevent …
sdassow Oct 27, 2024
25a082b
Sort flags alphabetically
sdassow Nov 2, 2024
972edf6
Add support for optionally scanning for translations in imports
sdassow Nov 2, 2024
5b5e816
Unbreak after cleanup
sdassow Nov 2, 2024
10d57ee
Don't trip over empty files
sdassow Nov 2, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
374 changes: 374 additions & 0 deletions cmd/fyne/internal/commands/translate.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,374 @@
package commands

import (
"encoding/json"
"errors"
"fmt"
"go/ast"
"go/parser"
"go/token"
"io"
"io/fs"
"os"
"path/filepath"
"strconv"

"github.com/urfave/cli/v2"
)

// Translate returns the cli command to scan for new translation strings.
func Translate() *cli.Command {
return &cli.Command{
Name: "translate",
Usage: "Scans for new translation strings.",
Flags: []cli.Flag{
&cli.BoolFlag{
Name: "verbose",
Aliases: []string{"v"},
Usage: "Show files that are being scanned etc.",
},
&cli.BoolFlag{
Name: "update",
Aliases: []string{"u"},
Usage: "Update existing translations (use with care).",
},
&cli.BoolFlag{
Name: "dry-run",
Aliases: []string{"n"},
Usage: "Scan without storing the results.",
},
&cli.StringFlag{
Name: "sourceDir",
Aliases: []string{"d"},
Usage: "Directory to scan recursively for go files.",
Value: ".",
},
&cli.StringFlag{
Name: "translationsFile",
Aliases: []string{"f"},
Usage: "File to read from and write translations to.",
Value: "translations/en.json",
},
},
Action: func(ctx *cli.Context) error {
sourceDir := ctx.String("sourceDir")
translationsFile := ctx.String("translationsFile")
files := ctx.Args().Slice()
opts := translateOpts{
DryRun: ctx.Bool("dry-run"),
Update: ctx.Bool("update"),
Verbose: ctx.Bool("verbose"),
}

// without any argument find all .go files in the source directory
if len(files) == 0 {
sources, err := findFilesExt(sourceDir, ".go")
if err != nil {
return err
}
files = sources
}

// update the translation file by scanning the given source files
sdassow marked this conversation as resolved.
Show resolved Hide resolved
return updateTranslationsFile(&opts, translationsFile, files)
},
}
}

// Recursively walk the given directory and return all files with the matching extension
func findFilesExt(dir, ext string) ([]string, error) {
files := []string{}
err := filepath.Walk(dir, func(path string, fi fs.FileInfo, err error) error {
if err != nil {
return err
}

if filepath.Ext(path) != ext || fi.IsDir() || !fi.Mode().IsRegular() {
return nil
}

files = append(files, path)

return nil
})
return files, err
}

type translateOpts struct {
DryRun bool
Update bool
Verbose bool
}

// Create or add to translations file by scanning the given files for translation calls
func updateTranslationsFile(opts *translateOpts, file string, files []string) error {
translations := make(map[string]interface{})

// try to get current translations first
f, err := os.Open(file)
if err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
defer f.Close()
sdassow marked this conversation as resolved.
Show resolved Hide resolved

// only try to parse translations that exist
if f != nil {
dec := json.NewDecoder(f)
if err := dec.Decode(&translations); err != nil {
return err
}
}

//if ctx.Bool("verbose") {
sdassow marked this conversation as resolved.
Show resolved Hide resolved
if opts.Verbose {
fmt.Fprintf(os.Stderr, "scanning files: %v\n", files)
}

// update translations hash
if err := updateTranslationsHash(opts, translations, files); err != nil {
return err
}

// serialize in readable format for humas to change
b, err := json.MarshalIndent(translations, "", "\t")
if err != nil {
return err
}

// avoid writing an empty file
if len(translations) == 0 {
sdassow marked this conversation as resolved.
Show resolved Hide resolved
fmt.Fprintln(os.Stderr, "No translations found")
return nil
}

// stop without making any changes
if opts.DryRun {
sdassow marked this conversation as resolved.
Show resolved Hide resolved
return nil
}

// support writing to stdout
if file == "-" {
fmt.Printf("%s\n", string(b))
return nil
}

return writeTranslationsFile(b, file, f)
}

// Write data to given file, optionally using same permissions as the original file
sdassow marked this conversation as resolved.
Show resolved Hide resolved
func writeTranslationsFile(b []byte, file string, f *os.File) error {
// default permissions
perm := fs.FileMode(0644)

// use same permissions as original file when possible
if f != nil {
fi, err := f.Stat()
if err != nil {
return err
}
perm = fi.Mode().Perm()
}

// use temporary file to do atomic change
nf, err := os.CreateTemp(filepath.Dir(file), filepath.Base(file)+"-*")
if err != nil {
return err
}

n, err := nf.Write(b)
if err != nil {
return err
}

if n < len(b) {
return io.ErrShortWrite
}

if err := nf.Chmod(perm); err != nil {
return err
}

if err := nf.Close(); err != nil {
return err
}

// atomic switch to new file
return os.Rename(nf.Name(), file)
}

// Update translations hash by scanning the given files
func updateTranslationsHash(opts *translateOpts, m map[string]interface{}, srcs []string) error {
for _, src := range srcs {
// get AST by parsing source
fset := token.NewFileSet()
af, err := parser.ParseFile(fset, src, nil, parser.AllErrors)
if err != nil {
return err
}

// walk AST to find known translation calls
ast.Walk(&visitor{opts: opts, m: m}, af)
}

return nil
}

// Visitor pattern with state machine to find translations fallback (and key)
type visitor struct {
opts *translateOpts
state stateFn
name string
key string
fallback string
m map[string]interface{}
}

// Method to walk AST using interface for ast.Walk
func (v *visitor) Visit(node ast.Node) ast.Visitor {
if node == nil {
return nil
}

// start over any time there is no state
if v.state == nil {
v.state = translateNew
v.name = ""
v.key = ""
v.fallback = ""
}

// run and get next state
v.state = v.state(v, node)

return v
}

// State machine to pick out translation key and fallback from AST
type stateFn func(*visitor, ast.Node) stateFn

// All translation calls need to start with the literal "lang"
func translateNew(v *visitor, node ast.Node) stateFn {
ident, ok := node.(*ast.Ident)
if !ok {
return nil
}

if ident.Name != "lang" {
return nil
}

return translateCall
}

// A known translation method needs to be used
func translateCall(v *visitor, node ast.Node) stateFn {
ident, ok := node.(*ast.Ident)
if !ok {
return nil
}

v.name = ident.Name

switch ident.Name {
// simple cases: only the first argument is relevant
case "L", "Localize":
return translateLocalize
case "N", "LocalizePlural":
return translateLocalize
// more complex cases: first two arguments matter
case "X", "LocalizeKey":
return translateKey
case "XN", "LocalizePluralKey":
return translateKey
}

return nil
}

// Parse first argument, use string as key and fallback, and finish
func translateLocalize(v *visitor, node ast.Node) stateFn {
basiclit, ok := node.(*ast.BasicLit)
if !ok {
return nil
}

val, err := strconv.Unquote(basiclit.Value)
if err != nil {
return nil
}

v.key = val
v.fallback = val

return translateFinish(v, node)
}

// Parse first argument and use as key
func translateKey(v *visitor, node ast.Node) stateFn {
basiclit, ok := node.(*ast.BasicLit)
if !ok {
return nil
}

val, err := strconv.Unquote(basiclit.Value)
if err != nil {
return nil
}

v.key = val

return translateKeyFallback
}

// Parse second argument and use as fallback, and finish
func translateKeyFallback(v *visitor, node ast.Node) stateFn {
basiclit, ok := node.(*ast.BasicLit)
if !ok {
return nil
}

val, err := strconv.Unquote(basiclit.Value)
if err != nil {
return nil
}

v.fallback = val

return translateFinish(v, node)
}

// Finish scan for translation and add to translation hash with the right type (singular or plural)
func translateFinish(v *visitor, node ast.Node) stateFn {
// only adding new keys, ignoring changed or removed (ha!) ones
// removing is dangerous as there could be dynamic keys that get removed

// Ignore existing translations to prevent unintentional overwriting
_, found := v.m[v.key]
if found {
if !v.opts.Update {
if v.opts.Verbose {
fmt.Fprintf(os.Stderr, "ignoring: %s\n", v.key)
}
return nil
}
if v.opts.Verbose {
fmt.Fprintf(os.Stderr, "updating: %s\n", v.key)
}
} else {
if v.opts.Verbose {
fmt.Fprintf(os.Stderr, "adding: %s\n", v.key)
}
}

switch v.name {
// Plural translations use a nested map
case "LocalizePlural", "LocalizePluralKey", "N", "XN":
m := make(map[string]string)
m["other"] = v.fallback
v.m[v.key] = m
default:
v.m[v.key] = v.fallback
}

return nil
}
Loading
Loading