From d86509507221897b25c2707040c688b748e7d363 Mon Sep 17 00:00:00 2001 From: Hariom Verma Date: Fri, 6 Oct 2023 01:31:58 +0530 Subject: [PATCH] feat: use `modfile` package to write modfile (#1077) The previous implementation manually iterates over the `Require` and `Replace` and writes them in the string var to construct the modfile, which is very inefficient and doesn't handles comments and other cases. Changed it use `modfile` package to write modfile(gno.mod/go.mod). It uses `*modfile.FileSyntax`. Copied few methods from `modfile` package to manipulate `*modfile.FileSyntax`.
Contributors' checklist... - [x] Added new tests, or not needed, or not feasible - [x] Provided an example (e.g. screenshot) to aid review or the PR is self-explanatory - [x] Updated the official documentation or not needed - [x] No breaking changes were made, or a `BREAKING CHANGE: xxx` message was included in the description - [x] Added references to related issues and PRs - [x] Provided any useful hints for running manual tests - [ ] Added new benchmarks to [generated graphs](https://gnoland.github.io/benchmarks), if any. More info [here](https://github.com/gnolang/gno/blob/master/.benchmarks/README.md).
--------- Co-authored-by: Manfred Touron <94029+moul@users.noreply.github.com> --- gnovm/cmd/gno/mod.go | 2 +- gnovm/pkg/gnomod/file.go | 162 +++++++--- gnovm/pkg/gnomod/gnomod.go | 37 +-- gnovm/pkg/gnomod/read.go | 209 ++++++++++++- gnovm/pkg/gnomod/read_test.go | 541 ++++++++++++++++++++++++++++++++++ 5 files changed, 875 insertions(+), 76 deletions(-) create mode 100644 gnovm/pkg/gnomod/read_test.go diff --git a/gnovm/cmd/gno/mod.go b/gnovm/cmd/gno/mod.go index 09194232fec..267b7d99237 100644 --- a/gnovm/cmd/gno/mod.go +++ b/gnovm/cmd/gno/mod.go @@ -126,7 +126,7 @@ func execModDownload(cfg *modDownloadCfg, args []string, io *commands.IO) error } // write go.mod file - err = gomod.WriteToPath(filepath.Join(path, "go.mod")) + err = gomod.Write(filepath.Join(path, "go.mod")) if err != nil { return fmt.Errorf("write go.mod file: %w", err) } diff --git a/gnovm/pkg/gnomod/file.go b/gnovm/pkg/gnomod/file.go index 71d98b2d14b..25189ebc71d 100644 --- a/gnovm/pkg/gnomod/file.go +++ b/gnovm/pkg/gnomod/file.go @@ -1,3 +1,12 @@ +// Some part of file is copied and modified from +// golang.org/x/mod/modfile/read.go +// +// Copyright 2018 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in here[1]. +// +// [1]: https://cs.opensource.google/go/x/mod/+/master:LICENSE + package gnomod import ( @@ -24,6 +33,100 @@ type File struct { Syntax *modfile.FileSyntax } +// AddRequire sets the first require line for path to version vers, +// preserving any existing comments for that line and removing all +// other lines for path. +// +// If no line currently exists for path, AddRequire adds a new line +// at the end of the last require block. +func (f *File) AddRequire(path, vers string) error { + need := true + for _, r := range f.Require { + if r.Mod.Path == path { + if need { + r.Mod.Version = vers + updateLine(r.Syntax, "require", modfile.AutoQuote(path), vers) + need = false + } else { + markLineAsRemoved(r.Syntax) + *r = modfile.Require{} + } + } + } + + if need { + f.AddNewRequire(path, vers, false) + } + return nil +} + +// AddNewRequire adds a new require line for path at version vers at the end of +// the last require block, regardless of any existing require lines for path. +func (f *File) AddNewRequire(path, vers string, indirect bool) { + line := addLine(f.Syntax, nil, "require", modfile.AutoQuote(path), vers) + r := &modfile.Require{ + Mod: module.Version{Path: path, Version: vers}, + Syntax: line, + } + setIndirect(r, indirect) + f.Require = append(f.Require, r) +} + +func (f *File) AddModuleStmt(path string) error { + if f.Syntax == nil { + f.Syntax = new(modfile.FileSyntax) + } + if f.Module == nil { + f.Module = &modfile.Module{ + Mod: module.Version{Path: path}, + Syntax: addLine(f.Syntax, nil, "module", modfile.AutoQuote(path)), + } + } else { + f.Module.Mod.Path = path + updateLine(f.Module.Syntax, "module", modfile.AutoQuote(path)) + } + return nil +} + +func (f *File) AddComment(text string) { + if f.Syntax == nil { + f.Syntax = new(modfile.FileSyntax) + } + f.Syntax.Stmt = append(f.Syntax.Stmt, &modfile.CommentBlock{ + Comments: modfile.Comments{ + Before: []modfile.Comment{ + { + Token: text, + }, + }, + }, + }) +} + +func (f *File) AddReplace(oldPath, oldVers, newPath, newVers string) error { + return addReplace(f.Syntax, &f.Replace, oldPath, oldVers, newPath, newVers) +} + +func (f *File) DropRequire(path string) error { + for _, r := range f.Require { + if r.Mod.Path == path { + markLineAsRemoved(r.Syntax) + *r = modfile.Require{} + } + } + return nil +} + +func (f *File) DropReplace(oldPath, oldVers string) error { + for _, r := range f.Replace { + if r.Old.Path == oldPath && r.Old.Version == oldVers { + markLineAsRemoved(r.Syntax) + *r = modfile.Replace{} + } + } + return nil +} + // Validate validates gno.mod func (f *File) Validate() error { if f.Module == nil { @@ -73,13 +176,8 @@ func (f *File) FetchDeps(path string, remote string, verbose bool) error { return fmt.Errorf("writepackage: %w", err) } - modFile := &File{ - Module: &modfile.Module{ - Mod: module.Version{ - Path: mod.Path, - }, - }, - } + modFile := new(File) + modFile.AddModuleStmt(mod.Path) for _, req := range requirements { path := req[1 : len(req)-1] // trim leading and trailing `"` if strings.HasSuffix(path, modFile.Module.Mod.Path) { @@ -92,13 +190,7 @@ func (f *File) FetchDeps(path string, remote string, verbose bool) error { if strings.HasPrefix(path, gnolang.ImportPrefix) { path = strings.TrimPrefix(path, gnolang.ImportPrefix+"/examples/") - modFile.Require = append(modFile.Require, &modfile.Require{ - Mod: module.Version{ - Path: path, - Version: "v0.0.0", // TODO: Use latest? - }, - Indirect: true, - }) + modFile.AddNewRequire(path, "v0.0.0-latest", true) } } @@ -112,7 +204,7 @@ func (f *File) FetchDeps(path string, remote string, verbose bool) error { } pkgPath := PackageDir(path, mod) goModFilePath := filepath.Join(pkgPath, "go.mod") - err = goMod.WriteToPath(goModFilePath) + err = goMod.Write(goModFilePath) if err != nil { return err } @@ -121,42 +213,14 @@ func (f *File) FetchDeps(path string, remote string, verbose bool) error { return nil } -// WriteToPath writes file to the given absolute file path -// TODO: Find better way to do this. Try to use `modfile` -// package to manage this. -func (f *File) WriteToPath(absFilePath string) error { - if f.Module == nil { - return errors.New("writing go.mod: module not found") - } - - data := "module " + f.Module.Mod.Path + "\n" - - if f.Go != nil { - data += "\ngo " + f.Go.Version + "\n" - } - - if f.Require != nil { - data += "\nrequire (" + "\n" - for _, req := range f.Require { - data += "\t" + req.Mod.Path + " " + req.Mod.Version + "\n" - } - data += ")\n" - } - - if f.Replace != nil { - data += "\nreplace (" + "\n" - for _, rep := range f.Replace { - data += "\t" + rep.Old.Path + " " + rep.Old.Version + - " => " + rep.New.Path + "\n" - } - data += ")\n" - } - - err := os.WriteFile(absFilePath, []byte(data), 0o644) +// writes file to the given absolute file path +func (f *File) Write(fname string) error { + f.Syntax.Cleanup() + data := modfile.Format(f.Syntax) + err := os.WriteFile(fname, data, 0o644) if err != nil { - return fmt.Errorf("writefile %q: %w", absFilePath, err) + return fmt.Errorf("writefile %q: %w", fname, err) } - return nil } diff --git a/gnovm/pkg/gnomod/gnomod.go b/gnovm/pkg/gnomod/gnomod.go index aa41c5aa00c..7bb51d6558a 100644 --- a/gnovm/pkg/gnomod/gnomod.go +++ b/gnovm/pkg/gnomod/gnomod.go @@ -100,7 +100,7 @@ func GnoToGoMod(f File) (*File, error) { if strings.HasPrefix(f.Module.Mod.Path, gnolang.GnoRealmPkgsPrefixBefore) || strings.HasPrefix(f.Module.Mod.Path, gnolang.GnoPackagePrefixBefore) { - f.Module.Mod.Path = gnolang.ImportPrefix + "/examples/" + f.Module.Mod.Path + f.AddModuleStmt(gnolang.ImportPrefix + "/examples/" + f.Module.Mod.Path) } for i := range f.Require { @@ -113,20 +113,17 @@ func GnoToGoMod(f File) (*File, error) { path := f.Require[i].Mod.Path if strings.HasPrefix(f.Require[i].Mod.Path, gnolang.GnoRealmPkgsPrefixBefore) || strings.HasPrefix(f.Require[i].Mod.Path, gnolang.GnoPackagePrefixBefore) { - f.Require[i].Mod.Path = gnolang.ImportPrefix + "/examples/" + f.Require[i].Mod.Path + // Add dependency with a modified import path + f.AddRequire(gnolang.ImportPrefix+"/examples/"+f.Require[i].Mod.Path, f.Require[i].Mod.Version) } - - f.Replace = append(f.Replace, &modfile.Replace{ - Old: module.Version{ - Path: f.Require[i].Mod.Path, - Version: f.Require[i].Mod.Version, - }, - New: module.Version{ - Path: filepath.Join(gnoModPath, path), - }, - }) + f.AddReplace(f.Require[i].Mod.Path, f.Require[i].Mod.Version, filepath.Join(gnoModPath, path), "") + // Remove the old require since the new dependency was added above + f.DropRequire(f.Require[i].Mod.Path) } + // Remove replacements that are not replaced by directories. + // + // Explanation: // By this stage every replacement should be replace by dir. // If not replaced by dir, remove it. // @@ -153,14 +150,11 @@ func GnoToGoMod(f File) (*File, error) { // ``` // // Remove `gno.land/p/demo/avl v1.2.3 => gno.land/p/demo/avl v3.2.1`. - repl := make([]*modfile.Replace, 0, len(f.Replace)) for _, r := range f.Replace { if !modfile.IsDirectoryPath(r.New.Path) { - continue + f.DropReplace(r.Old.Path, r.Old.Version) } - repl = append(repl, r) } - f.Replace = repl return &f, nil } @@ -215,14 +209,9 @@ func CreateGnoModFile(rootDir, modPath string) error { return err } - modFile := &File{ - Module: &modfile.Module{ - Mod: module.Version{ - Path: modPath, - }, - }, - } - modFile.WriteToPath(filepath.Join(rootDir, "gno.mod")) + modfile := new(File) + modfile.AddModuleStmt(modPath) + modfile.Write(filepath.Join(rootDir, "gno.mod")) return nil } diff --git a/gnovm/pkg/gnomod/read.go b/gnovm/pkg/gnomod/read.go index 206c843f86a..9bbed3c4651 100644 --- a/gnovm/pkg/gnomod/read.go +++ b/gnovm/pkg/gnomod/read.go @@ -3,9 +3,10 @@ // license that can be found in here[1]. // // [1]: https://cs.opensource.google/go/x/mod/+/master:LICENSE -// Original Filepath: golang.org/x/mod/modfile/read.go // -// Note: This file may contain some modifications. +// Mostly copied and modified from: +// - golang.org/x/mod/modfile/read.go +// - golang.org/x/mod/modfile/rule.go package gnomod @@ -845,3 +846,207 @@ func parseDraft(block *modfile.CommentBlock) bool { } return true } + +// markLineAsRemoved modifies line so that it (and its end-of-line comment, if any) +// will be dropped by (*FileSyntax).Cleanup. +func markLineAsRemoved(line *modfile.Line) { + line.Token = nil + line.Comments.Suffix = nil +} + +func updateLine(line *modfile.Line, tokens ...string) { + if line.InBlock { + tokens = tokens[1:] + } + line.Token = tokens +} + +// setIndirect sets line to have (or not have) a "// indirect" comment. +func setIndirect(r *modfile.Require, indirect bool) { + r.Indirect = indirect + line := r.Syntax + if isIndirect(line) == indirect { + return + } + if indirect { + // Adding comment. + if len(line.Suffix) == 0 { + // New comment. + line.Suffix = []modfile.Comment{{Token: "// indirect", Suffix: true}} + return + } + + com := &line.Suffix[0] + text := strings.TrimSpace(strings.TrimPrefix(com.Token, string(slashSlash))) + if text == "" { + // Empty comment. + com.Token = "// indirect" + return + } + + // Insert at beginning of existing comment. + com.Token = "// indirect; " + text + return + } + + // Removing comment. + f := strings.TrimSpace(strings.TrimPrefix(line.Suffix[0].Token, string(slashSlash))) + if f == "indirect" { + // Remove whole comment. + line.Suffix = nil + return + } + + // Remove comment prefix. + com := &line.Suffix[0] + i := strings.Index(com.Token, "indirect;") + com.Token = "//" + com.Token[i+len("indirect;"):] +} + +// isIndirect reports whether line has a "// indirect" comment, +// meaning it is in go.mod only for its effect on indirect dependencies, +// so that it can be dropped entirely once the effective version of the +// indirect dependency reaches the given minimum version. +func isIndirect(line *modfile.Line) bool { + if len(line.Suffix) == 0 { + return false + } + f := strings.Fields(strings.TrimPrefix(line.Suffix[0].Token, string(slashSlash))) + return (len(f) == 1 && f[0] == "indirect" || len(f) > 1 && f[0] == "indirect;") +} + +// addLine adds a line containing the given tokens to the file. +// +// If the first token of the hint matches the first token of the +// line, the new line is added at the end of the block containing hint, +// extracting hint into a new block if it is not yet in one. +// +// If the hint is non-nil buts its first token does not match, +// the new line is added after the block containing hint +// (or hint itself, if not in a block). +// +// If no hint is provided, addLine appends the line to the end of +// the last block with a matching first token, +// or to the end of the file if no such block exists. +func addLine(x *modfile.FileSyntax, hint modfile.Expr, tokens ...string) *modfile.Line { + if hint == nil { + // If no hint given, add to the last statement of the given type. + Loop: + for i := len(x.Stmt) - 1; i >= 0; i-- { + stmt := x.Stmt[i] + switch stmt := stmt.(type) { + case *modfile.Line: + if stmt.Token != nil && stmt.Token[0] == tokens[0] { + hint = stmt + break Loop + } + case *modfile.LineBlock: + if stmt.Token[0] == tokens[0] { + hint = stmt + break Loop + } + } + } + } + + newLineAfter := func(i int) *modfile.Line { + newl := &modfile.Line{Token: tokens} + if i == len(x.Stmt) { + x.Stmt = append(x.Stmt, newl) + } else { + x.Stmt = append(x.Stmt, nil) + copy(x.Stmt[i+2:], x.Stmt[i+1:]) + x.Stmt[i+1] = newl + } + return newl + } + + if hint != nil { + for i, stmt := range x.Stmt { + switch stmt := stmt.(type) { + case *modfile.Line: + if stmt == hint { + if stmt.Token == nil || stmt.Token[0] != tokens[0] { + return newLineAfter(i) + } + + // Convert line to line block. + stmt.InBlock = true + block := &modfile.LineBlock{Token: stmt.Token[:1], Line: []*modfile.Line{stmt}} + stmt.Token = stmt.Token[1:] + x.Stmt[i] = block + newl := &modfile.Line{Token: tokens[1:], InBlock: true} + block.Line = append(block.Line, newl) + return newl + } + + case *modfile.LineBlock: + if stmt == hint { + if stmt.Token[0] != tokens[0] { + return newLineAfter(i) + } + + newl := &modfile.Line{Token: tokens[1:], InBlock: true} + stmt.Line = append(stmt.Line, newl) + return newl + } + + for j, line := range stmt.Line { + if line == hint { + if stmt.Token[0] != tokens[0] { + return newLineAfter(i) + } + + // Add new line after hint within the block. + stmt.Line = append(stmt.Line, nil) + copy(stmt.Line[j+2:], stmt.Line[j+1:]) + newl := &modfile.Line{Token: tokens[1:], InBlock: true} + stmt.Line[j+1] = newl + return newl + } + } + } + } + } + + newl := &modfile.Line{Token: tokens} + x.Stmt = append(x.Stmt, newl) + return newl +} + +func addReplace(syntax *modfile.FileSyntax, replace *[]*modfile.Replace, oldPath, oldVers, newPath, newVers string) error { + need := true + oldv := module.Version{Path: oldPath, Version: oldVers} + newv := module.Version{Path: newPath, Version: newVers} + tokens := []string{"replace", modfile.AutoQuote(oldPath)} + if oldVers != "" { + tokens = append(tokens, oldVers) + } + tokens = append(tokens, "=>", modfile.AutoQuote(newPath)) + if newVers != "" { + tokens = append(tokens, newVers) + } + + var hint *modfile.Line + for _, r := range *replace { + if r.Old.Path == oldPath && (oldVers == "" || r.Old.Version == oldVers) { + if need { + // Found replacement for old; update to use new. + r.New = newv + updateLine(r.Syntax, tokens...) + need = false + continue + } + // Already added; delete other replacements for same. + markLineAsRemoved(r.Syntax) + *r = modfile.Replace{} + } + if r.Old.Path == oldPath { + hint = r.Syntax + } + } + if need { + *replace = append(*replace, &modfile.Replace{Old: oldv, New: newv, Syntax: addLine(syntax, hint, tokens...)}) + } + return nil +} diff --git a/gnovm/pkg/gnomod/read_test.go b/gnovm/pkg/gnomod/read_test.go new file mode 100644 index 00000000000..cf3b6f59076 --- /dev/null +++ b/gnovm/pkg/gnomod/read_test.go @@ -0,0 +1,541 @@ +// Copyright 2018 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 gnomod + +import ( + "bytes" + "fmt" + "strings" + "testing" + + "golang.org/x/mod/modfile" +) + +// TestParsePunctuation verifies that certain ASCII punctuation characters +// (brackets, commas) are lexed as separate tokens, even when they're +// surrounded by identifier characters. +func TestParsePunctuation(t *testing.T) { + for _, test := range []struct { + desc, src, want string + }{ + {"paren", "require ()", "require ( )"}, + {"brackets", "require []{},", "require [ ] { } ,"}, + {"mix", "require a[b]c{d}e,", "require a [ b ] c { d } e ,"}, + {"block_mix", "require (\n\ta[b]\n)", "require ( a [ b ] )"}, + {"interval", "require [v1.0.0, v1.1.0)", "require [ v1.0.0 , v1.1.0 )"}, + } { + t.Run(test.desc, func(t *testing.T) { + f, err := parse("gno.mod", []byte(test.src)) + if err != nil { + t.Fatalf("parsing %q: %v", test.src, err) + } + var tokens []string + for _, stmt := range f.Stmt { + switch stmt := stmt.(type) { + case *modfile.Line: + tokens = append(tokens, stmt.Token...) + case *modfile.LineBlock: + tokens = append(tokens, stmt.Token...) + tokens = append(tokens, "(") + for _, line := range stmt.Line { + tokens = append(tokens, line.Token...) + } + tokens = append(tokens, ")") + default: + t.Fatalf("parsing %q: unexpected statement of type %T", test.src, stmt) + } + } + got := strings.Join(tokens, " ") + if got != test.want { + t.Errorf("parsing %q: got %q, want %q", test.src, got, test.want) + } + }) + } +} + +var modulePathTests = []struct { + input []byte + expected string +}{ + {input: []byte("module \"github.com/rsc/vgotest\""), expected: "github.com/rsc/vgotest"}, + {input: []byte("module github.com/rsc/vgotest"), expected: "github.com/rsc/vgotest"}, + {input: []byte("module \"github.com/rsc/vgotest\""), expected: "github.com/rsc/vgotest"}, + {input: []byte("module github.com/rsc/vgotest"), expected: "github.com/rsc/vgotest"}, + {input: []byte("module `github.com/rsc/vgotest`"), expected: "github.com/rsc/vgotest"}, + {input: []byte("module \"github.com/rsc/vgotest/v2\""), expected: "github.com/rsc/vgotest/v2"}, + {input: []byte("module github.com/rsc/vgotest/v2"), expected: "github.com/rsc/vgotest/v2"}, + {input: []byte("module \"gopkg.in/yaml.v2\""), expected: "gopkg.in/yaml.v2"}, + {input: []byte("module gopkg.in/yaml.v2"), expected: "gopkg.in/yaml.v2"}, + {input: []byte("module \"gopkg.in/check.v1\"\n"), expected: "gopkg.in/check.v1"}, + {input: []byte("module \"gopkg.in/check.v1\n\""), expected: ""}, + {input: []byte("module gopkg.in/check.v1\n"), expected: "gopkg.in/check.v1"}, + {input: []byte("module \"gopkg.in/check.v1\"\r\n"), expected: "gopkg.in/check.v1"}, + {input: []byte("module gopkg.in/check.v1\r\n"), expected: "gopkg.in/check.v1"}, + {input: []byte("module \"gopkg.in/check.v1\"\n\n"), expected: "gopkg.in/check.v1"}, + {input: []byte("module gopkg.in/check.v1\n\n"), expected: "gopkg.in/check.v1"}, + {input: []byte("module \n\"gopkg.in/check.v1\"\n\n"), expected: ""}, + {input: []byte("module \ngopkg.in/check.v1\n\n"), expected: ""}, + {input: []byte("module \"gopkg.in/check.v1\"asd"), expected: ""}, + {input: []byte("module \n\"gopkg.in/check.v1\"\n\n"), expected: ""}, + {input: []byte("module \ngopkg.in/check.v1\n\n"), expected: ""}, + {input: []byte("module \"gopkg.in/check.v1\"asd"), expected: ""}, + {input: []byte("module \nmodule a/b/c "), expected: "a/b/c"}, + {input: []byte("module \" \""), expected: " "}, + {input: []byte("module "), expected: ""}, + {input: []byte("module \" a/b/c \""), expected: " a/b/c "}, + {input: []byte("module \"github.com/rsc/vgotest1\" // with a comment"), expected: "github.com/rsc/vgotest1"}, +} + +func TestModulePath(t *testing.T) { + for _, test := range modulePathTests { + t.Run(string(test.input), func(t *testing.T) { + result := ModulePath(test.input) + if result != test.expected { + t.Fatalf("ModulePath(%q): %s, want %s", string(test.input), result, test.expected) + } + }) + } +} + +func TestParseVersions(t *testing.T) { + tests := []struct { + desc, input string + ok bool + }{ + // go lines + {desc: "empty", input: "module m\ngo \n", ok: false}, + {desc: "one", input: "module m\ngo 1\n", ok: false}, + {desc: "two", input: "module m\ngo 1.22\n", ok: true}, + {desc: "three", input: "module m\ngo 1.22.333", ok: true}, + {desc: "before", input: "module m\ngo v1.2\n", ok: false}, + {desc: "after", input: "module m\ngo 1.2rc1\n", ok: true}, + {desc: "space", input: "module m\ngo 1.2 3.4\n", ok: false}, + {desc: "alt1", input: "module m\ngo 1.2.3\n", ok: true}, + {desc: "alt2", input: "module m\ngo 1.2rc1\n", ok: true}, + {desc: "alt3", input: "module m\ngo 1.2beta1\n", ok: true}, + {desc: "alt4", input: "module m\ngo 1.2.beta1\n", ok: false}, + } + t.Run("Strict", func(t *testing.T) { + for _, test := range tests { + t.Run(test.desc, func(t *testing.T) { + if _, err := Parse("gno.mod", []byte(test.input)); err == nil && !test.ok { + t.Error("unexpected success") + } else if err != nil && test.ok { + t.Errorf("unexpected error: %v", err) + } + }) + } + }) +} + +func TestComments(t *testing.T) { + for _, test := range []struct { + desc, input, want string + }{ + { + desc: "comment_only", + input: ` +// a +// b +`, + want: ` +comments before "// a" +comments before "// b" +`, + }, { + desc: "line", + input: ` +// a + +// b +module m // c +// d + +// e +`, + want: ` +comments before "// a" +line before "// b" +line suffix "// c" +comments before "// d" +comments before "// e" +`, + }, { + desc: "cr_removed", + input: "// a\r\r\n", + want: `comments before "// a\r"`, + }, + } { + t.Run(test.desc, func(t *testing.T) { + f, err := Parse("gno.mod", []byte(test.input)) + if err != nil { + t.Fatal(err) + } + + if test.desc == "block" { + panic("hov") + } + + buf := &bytes.Buffer{} + printComments := func(prefix string, cs *modfile.Comments) { + for _, c := range cs.Before { + fmt.Fprintf(buf, "%s before %q\n", prefix, c.Token) + } + for _, c := range cs.Suffix { + fmt.Fprintf(buf, "%s suffix %q\n", prefix, c.Token) + } + for _, c := range cs.After { + fmt.Fprintf(buf, "%s after %q\n", prefix, c.Token) + } + } + + printComments("file", &f.Syntax.Comments) + for _, stmt := range f.Syntax.Stmt { + switch stmt := stmt.(type) { + case *modfile.CommentBlock: + printComments("comments", stmt.Comment()) + case *modfile.Line: + printComments("line", stmt.Comment()) + } + } + + got := strings.TrimSpace(buf.String()) + want := strings.TrimSpace(test.want) + if got != want { + t.Errorf("got:\n%s\nwant:\n%s", got, want) + } + }) + } +} + +var addRequireTests = []struct { + desc string + in string + path string + vers string + out string +}{ + { + `existing`, + ` + module m + require x.y/z v1.2.3 + `, + "x.y/z", "v1.5.6", + ` + module m + require x.y/z v1.5.6 + `, + }, + { + `existing2`, + ` + module m + require ( + x.y/z v1.2.3 // first + x.z/a v0.1.0 // first-a + ) + require x.y/z v1.4.5 // second + require ( + x.y/z v1.6.7 // third + x.z/a v0.2.0 // third-a + ) + `, + "x.y/z", "v1.8.9", + ` + module m + + require ( + x.y/z v1.8.9 // first + x.z/a v0.1.0 // first-a + ) + + require x.z/a v0.2.0 // third-a + `, + }, + { + `new`, + ` + module m + require x.y/z v1.2.3 + `, + "x.y/w", "v1.5.6", + ` + module m + require ( + x.y/z v1.2.3 + x.y/w v1.5.6 + ) + `, + }, + { + `new2`, + ` + module m + require x.y/z v1.2.3 + require x.y/q/v2 v2.3.4 + `, + "x.y/w", "v1.5.6", + ` + module m + require x.y/z v1.2.3 + require ( + x.y/q/v2 v2.3.4 + x.y/w v1.5.6 + ) + `, + }, +} + +var addModuleStmtTests = []struct { + desc string + in string + path string + out string +}{ + { + `existing`, + ` + module m + require x.y/z v1.2.3 + `, + "n", + ` + module n + require x.y/z v1.2.3 + `, + }, + { + `new`, + ``, + "m", + ` + module m + `, + }, +} + +var addReplaceTests = []struct { + desc string + in string + oldPath string + oldVers string + newPath string + newVers string + out string +}{ + { + `replace_with_module`, + ` + module m + require x.y/z v1.2.3 + `, + "x.y/z", + "v1.5.6", + "a.b/c", + "v1.5.6", + ` + module m + require x.y/z v1.2.3 + replace x.y/z v1.5.6 => a.b/c v1.5.6 + `, + }, + { + `replace_with_dir`, + ` + module m + require x.y/z v1.2.3 + `, + "x.y/z", + "v1.5.6", + "/path/to/dir", + "", + ` + module m + require x.y/z v1.2.3 + replace x.y/z v1.5.6 => /path/to/dir + `, + }, +} + +var dropRequireTests = []struct { + desc string + in string + path string + out string +}{ + { + `existing`, + ` + module m + require x.y/z v1.2.3 + `, + "x.y/z", + ` + module m + `, + }, + { + `existing2`, + ` + module m + require ( + x.y/z v1.2.3 // first + x.z/a v0.1.0 // first-a + ) + require x.y/z v1.4.5 // second + require ( + x.y/z v1.6.7 // third + x.z/a v0.2.0 // third-a + ) + `, + "x.y/z", + ` + module m + + require x.z/a v0.1.0 // first-a + + require x.z/a v0.2.0 // third-a + `, + }, + { + `not_exists`, + ` + module m + require x.y/z v1.2.3 + `, + "a.b/c", + ` + module m + require x.y/z v1.2.3 + `, + }, +} + +var dropReplaceTests = []struct { + desc string + in string + path string + vers string + out string +}{ + { + `existing`, + ` + module m + require x.y/z v1.2.3 + + replace x.y/z v1.2.3 => a.b/c v1.5.6 + `, + "x.y/z", + "v1.2.3", + ` + module m + require x.y/z v1.2.3 + `, + }, + { + `not_exists`, + ` + module m + require x.y/z v1.2.3 + + replace x.y/z v1.2.3 => a.b/c v1.5.6 + `, + "a.b/c", + "v3.2.1", + ` + module m + require x.y/z v1.2.3 + + replace x.y/z v1.2.3 => a.b/c v1.5.6 + `, + }, +} + +func TestAddRequire(t *testing.T) { + for _, tt := range addRequireTests { + t.Run(tt.desc, func(t *testing.T) { + testEdit(t, tt.in, tt.out, func(f *File) error { + err := f.AddRequire(tt.path, tt.vers) + f.Syntax.Cleanup() + return err + }) + }) + } +} + +func TestAddModuleStmt(t *testing.T) { + for _, tt := range addModuleStmtTests { + t.Run(tt.desc, func(t *testing.T) { + testEdit(t, tt.in, tt.out, func(f *File) error { + err := f.AddModuleStmt(tt.path) + f.Syntax.Cleanup() + return err + }) + }) + } +} + +func TestAddReplace(t *testing.T) { + for _, tt := range addReplaceTests { + t.Run(tt.desc, func(t *testing.T) { + testEdit(t, tt.in, tt.out, func(f *File) error { + f.AddReplace(tt.oldPath, tt.oldVers, tt.newPath, tt.newVers) + f.Syntax.Cleanup() + return nil + }) + }) + } +} + +func TestDropRequire(t *testing.T) { + for _, tt := range dropRequireTests { + t.Run(tt.desc, func(t *testing.T) { + testEdit(t, tt.in, tt.out, func(f *File) error { + err := f.DropRequire(tt.path) + f.Syntax.Cleanup() + return err + }) + }) + } +} + +func TestDropReplace(t *testing.T) { + for _, tt := range dropReplaceTests { + t.Run(tt.desc, func(t *testing.T) { + testEdit(t, tt.in, tt.out, func(f *File) error { + err := f.DropReplace(tt.path, tt.vers) + f.Syntax.Cleanup() + return err + }) + }) + } +} + +func testEdit(t *testing.T, in, want string, transform func(f *File) error) *File { + t.Helper() + f, err := Parse("in", []byte(in)) + if err != nil { + t.Fatal(err) + } + g, err := Parse("out", []byte(want)) + if err != nil { + t.Fatal(err) + } + golden := modfile.Format(g.Syntax) + if err := transform(f); err != nil { + t.Fatal(err) + } + out := modfile.Format(f.Syntax) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(out, golden) { + t.Errorf("have:\n%s\nwant:\n%s", out, golden) + } + + return f +}