-
Notifications
You must be signed in to change notification settings - Fork 2.3k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
cmd/gonew: add new tool for starting a module by copying one
This is an experimental command that perhaps would become "go new" once we have more experience with it. We want to enable people to experiment with it and write their own templates and see how it works, and for that we need to put it in a place where it's reasonable to ask users to fetch it from. That place is golang.org/x/tools/cmd/gonew. There is an earlier copy in rsc.io/tmp/gonew, but that isn't the right place for end users to be fetching something to try. Once the tool is checked in I intend to start a GitHub discussion asking for feedback and suggestions about what is missing. I hope we will be able to identify core functionality that handles a large fraction of use cases. I've been using the earlier version myself for a while, and I've found it very convenient even in other contexts, like I want the code for a given module and don't want to go look up its Git repo and so on: go new rsc.io/[email protected] cd quote Change-Id: Ifc27cbd5d87ded89bc707b087b3f08fa70b1ef07 Reviewed-on: https://go-review.googlesource.com/c/tools/+/513737 gopls-CI: kokoro <[email protected]> Run-TryBot: Russ Cox <[email protected]> Auto-Submit: Russ Cox <[email protected]> Reviewed-by: Robert Findley <[email protected]> TryBot-Result: Gopher Robot <[email protected]>
- Loading branch information
Showing
3 changed files
with
475 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,233 @@ | ||
// Copyright 2023 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. | ||
|
||
// Gonew starts a new Go module by copying a template module. | ||
// | ||
// Usage: | ||
// | ||
// gonew srcmod[@version] [dstmod [dir]] | ||
// | ||
// Gonew makes a copy of the srcmod module, changing its module path to dstmod. | ||
// It writes that new module to a new directory named by dir. | ||
// If dir already exists, it must be an empty directory. | ||
// If dir is omitted, gonew uses ./elem where elem is the final path element of dstmod. | ||
// | ||
// This command is highly experimental and subject to change. | ||
// | ||
// # Example | ||
// | ||
// To install gonew: | ||
// | ||
// go install golang.org/x/tools/cmd/gonew@latest | ||
// | ||
// To clone the basic command-line program template golang.org/x/example/hello | ||
// as your.domain/myprog, in the directory ./myprog: | ||
// | ||
// gonew golang.org/x/example/hello your.domain/myprog | ||
// | ||
// To clone the latest copy of the rsc.io/quote module, keeping that module path, | ||
// into ./quote: | ||
// | ||
// gonew rsc.io/quote | ||
package main | ||
|
||
import ( | ||
"bytes" | ||
"encoding/json" | ||
"flag" | ||
"fmt" | ||
"go/parser" | ||
"go/token" | ||
"io/fs" | ||
"log" | ||
"os" | ||
"os/exec" | ||
"path" | ||
"path/filepath" | ||
"strconv" | ||
"strings" | ||
|
||
"golang.org/x/mod/modfile" | ||
"golang.org/x/mod/module" | ||
"golang.org/x/tools/internal/edit" | ||
) | ||
|
||
func usage() { | ||
fmt.Fprintf(os.Stderr, "usage: gonew srcmod[@version] [dstmod [dir]]\n") | ||
fmt.Fprintf(os.Stderr, "See https://pkg.go.dev/golang.org/x/tools/cmd/gonew.\n") | ||
os.Exit(2) | ||
} | ||
|
||
func main() { | ||
log.SetPrefix("gonew: ") | ||
log.SetFlags(0) | ||
flag.Usage = usage | ||
flag.Parse() | ||
args := flag.Args() | ||
|
||
if len(args) < 1 || len(args) > 3 { | ||
usage() | ||
} | ||
|
||
srcMod := args[0] | ||
srcModVers := srcMod | ||
if !strings.Contains(srcModVers, "@") { | ||
srcModVers += "@latest" | ||
} | ||
srcMod, _, _ = strings.Cut(srcMod, "@") | ||
if err := module.CheckPath(srcMod); err != nil { | ||
log.Fatalf("invalid source module name: %v", err) | ||
} | ||
|
||
dstMod := srcMod | ||
if len(args) >= 2 { | ||
dstMod = args[1] | ||
if err := module.CheckPath(dstMod); err != nil { | ||
log.Fatalf("invalid destination module name: %v", err) | ||
} | ||
} | ||
|
||
var dir string | ||
if len(args) == 3 { | ||
dir = args[2] | ||
} else { | ||
dir = "." + string(filepath.Separator) + path.Base(dstMod) | ||
} | ||
|
||
// Dir must not exist or must be an empty directory. | ||
de, err := os.ReadDir(dir) | ||
if err == nil && len(de) > 0 { | ||
log.Fatalf("target directory %s exists and is non-empty", dir) | ||
} | ||
needMkdir := err != nil | ||
|
||
var stdout, stderr bytes.Buffer | ||
cmd := exec.Command("go", "mod", "download", "-json", srcModVers) | ||
cmd.Stdout = &stdout | ||
cmd.Stderr = &stderr | ||
if err := cmd.Run(); err != nil { | ||
log.Fatalf("go mod download -json %s: %v\n%s%s", srcModVers, err, stderr.Bytes(), stdout.Bytes()) | ||
} | ||
|
||
var info struct { | ||
Dir string | ||
} | ||
if err := json.Unmarshal(stdout.Bytes(), &info); err != nil { | ||
log.Fatalf("go mod download -json %s: invalid JSON output: %v\n%s%s", srcMod, err, stderr.Bytes(), stdout.Bytes()) | ||
} | ||
|
||
if needMkdir { | ||
if err := os.MkdirAll(dir, 0777); err != nil { | ||
log.Fatal(err) | ||
} | ||
} | ||
|
||
// Copy from module cache into new directory, making edits as needed. | ||
filepath.WalkDir(info.Dir, func(src string, d fs.DirEntry, err error) error { | ||
if err != nil { | ||
log.Fatal(err) | ||
} | ||
rel, err := filepath.Rel(info.Dir, src) | ||
if err != nil { | ||
log.Fatal(err) | ||
} | ||
dst := filepath.Join(dir, rel) | ||
if d.IsDir() { | ||
if err := os.MkdirAll(dst, 0777); err != nil { | ||
log.Fatal(err) | ||
} | ||
return nil | ||
} | ||
|
||
data, err := os.ReadFile(src) | ||
if err != nil { | ||
log.Fatal(err) | ||
} | ||
|
||
isRoot := !strings.Contains(rel, string(filepath.Separator)) | ||
if strings.HasSuffix(rel, ".go") { | ||
data = fixGo(data, rel, srcMod, dstMod, isRoot) | ||
} | ||
if rel == "go.mod" { | ||
data = fixGoMod(data, srcMod, dstMod) | ||
} | ||
|
||
if err := os.WriteFile(dst, data, 0666); err != nil { | ||
log.Fatal(err) | ||
} | ||
return nil | ||
}) | ||
|
||
log.Printf("initialized %s in %s", dstMod, dir) | ||
} | ||
|
||
// fixGo rewrites the Go source in data to replace srcMod with dstMod. | ||
// isRoot indicates whether the file is in the root directory of the module, | ||
// in which case we also update the package name. | ||
func fixGo(data []byte, file string, srcMod, dstMod string, isRoot bool) []byte { | ||
fset := token.NewFileSet() | ||
f, err := parser.ParseFile(fset, file, data, parser.ImportsOnly) | ||
if err != nil { | ||
log.Fatalf("parsing source module:\n%s", err) | ||
} | ||
|
||
buf := edit.NewBuffer(data) | ||
at := func(p token.Pos) int { | ||
return fset.File(p).Offset(p) | ||
} | ||
|
||
srcName := path.Base(srcMod) | ||
dstName := path.Base(dstMod) | ||
if isRoot { | ||
if name := f.Name.Name; name == srcName || name == srcName+"_test" { | ||
dname := dstName + strings.TrimPrefix(name, srcName) | ||
if !token.IsIdentifier(dname) { | ||
log.Fatalf("%s: cannot rename package %s to package %s: invalid package name", file, name, dname) | ||
} | ||
buf.Replace(at(f.Name.Pos()), at(f.Name.End()), dname) | ||
} | ||
} | ||
|
||
for _, spec := range f.Imports { | ||
path, err := strconv.Unquote(spec.Path.Value) | ||
if err != nil { | ||
continue | ||
} | ||
if path == srcMod { | ||
if srcName != dstName && spec.Name == nil { | ||
// Add package rename because source code uses original name. | ||
// The renaming looks strange, but template authors are unlikely to | ||
// create a template where the root package is imported by packages | ||
// in subdirectories, and the renaming at least keeps the code working. | ||
// A more sophisticated approach would be to rename the uses of | ||
// the package identifier in the file too, but then you have to worry about | ||
// name collisions, and given how unlikely this is, it doesn't seem worth | ||
// trying to clean up the file that way. | ||
buf.Insert(at(spec.Path.Pos()), srcName+" ") | ||
} | ||
// Change import path to dstMod | ||
buf.Replace(at(spec.Path.Pos()), at(spec.Path.End()), strconv.Quote(dstMod)) | ||
} | ||
if strings.HasPrefix(path, srcMod+"/") { | ||
// Change import path to begin with dstMod | ||
buf.Replace(at(spec.Path.Pos()), at(spec.Path.End()), strconv.Quote(strings.Replace(path, srcMod, dstMod, 1))) | ||
} | ||
} | ||
return buf.Bytes() | ||
} | ||
|
||
// fixGoMod rewrites the go.mod content in data to replace srcMod with dstMod | ||
// in the module path. | ||
func fixGoMod(data []byte, srcMod, dstMod string) []byte { | ||
f, err := modfile.ParseLax("go.mod", data, nil) | ||
if err != nil { | ||
log.Fatalf("parsing source module:\n%s", err) | ||
} | ||
f.AddModuleStmt(dstMod) | ||
new, err := f.Format() | ||
if err != nil { | ||
return data | ||
} | ||
return new | ||
} |
Oops, something went wrong.