modfile: return structured errors from Parse and ParseLax

Parse and ParseLax now return an ErrorList, which is a slice of Errors
that satisfies the error interface. Each Error now contains a file
name and position. The verb and module path fields are optional.

Fixes golang/go#36486

Change-Id: I2a244c8753cfce4bb290ddb8bcea5ee73c45f79f
Reviewed-on: https://go-review.googlesource.com/c/mod/+/214430
Run-TryBot: Jay Conrod <jayconrod@google.com>
TryBot-Result: Gobot Gobot <gobot@golang.org>
Reviewed-by: Rohan Challa <rohan@golang.org>
Reviewed-by: Michael Matloob <matloob@golang.org>
diff --git a/modfile/read.go b/modfile/read.go
index 616d00e..1577efd 100644
--- a/modfile/read.go
+++ b/modfile/read.go
@@ -9,6 +9,7 @@
 
 import (
 	"bytes"
+	"errors"
 	"fmt"
 	"os"
 	"strconv"
@@ -333,8 +334,8 @@
 	endRule   int       // position of end of current rule
 
 	// Parser state.
-	file       *FileSyntax // returned top-level syntax tree
-	parseError error       // error encountered during parsing
+	file        *FileSyntax // returned top-level syntax tree
+	parseErrors ErrorList   // errors encountered during parsing
 
 	// Comment assignment state.
 	pre  []Expr // all expressions, in preorder traversal
@@ -358,19 +359,22 @@
 	// Turn both into error returns. Catching bug panics is
 	// especially important when processing many files.
 	defer func() {
-		if e := recover(); e != nil {
-			if e == in.parseError {
-				err = in.parseError
-			} else {
-				err = fmt.Errorf("%s:%d:%d: internal error: %v", in.filename, in.pos.Line, in.pos.LineRune, e)
-			}
+		if e := recover(); e != nil && e != &in.parseErrors {
+			in.parseErrors = append(in.parseErrors, Error{
+				Filename: in.filename,
+				Pos:      in.pos,
+				Err:      fmt.Errorf("internal error: %v", e),
+			})
+		}
+		if err == nil && len(in.parseErrors) > 0 {
+			err = in.parseErrors
 		}
 	}()
 
 	// Invoke the parser.
 	in.parseFile()
-	if in.parseError != nil {
-		return nil, in.parseError
+	if len(in.parseErrors) > 0 {
+		return nil, in.parseErrors
 	}
 	in.file.Name = in.filename
 
@@ -387,8 +391,12 @@
 	if s == "syntax error" && in.lastToken != "" {
 		s += " near " + in.lastToken
 	}
-	in.parseError = fmt.Errorf("%s:%d:%d: %v", in.filename, in.pos.Line, in.pos.LineRune, s)
-	panic(in.parseError)
+	in.parseErrors = append(in.parseErrors, Error{
+		Filename: in.filename,
+		Pos:      in.pos,
+		Err:      errors.New(s),
+	})
+	panic(&in.parseErrors)
 }
 
 // eof reports whether the input has reached end of file.
@@ -518,7 +526,7 @@
 		}
 
 		if in.peekPrefix("/*") {
-			in.Error(fmt.Sprintf("mod files must use // comments (not /* */ comments)"))
+			in.Error("mod files must use // comments (not /* */ comments)")
 		}
 
 		// Found non-space non-comment.
@@ -587,7 +595,7 @@
 			break
 		}
 		if in.peekPrefix("/*") {
-			in.Error(fmt.Sprintf("mod files must use // comments (not /* */ comments)"))
+			in.Error("mod files must use // comments (not /* */ comments)")
 		}
 		in.readRune()
 	}
@@ -668,7 +676,7 @@
 	for _, x := range in.pre {
 		start, _ := x.Span()
 		if debug {
-			fmt.Printf("pre %T :%d:%d #%d\n", x, start.Line, start.LineRune, start.Byte)
+			fmt.Fprintf(os.Stderr, "pre %T :%d:%d #%d\n", x, start.Line, start.LineRune, start.Byte)
 		}
 		xcom := x.Comment()
 		for len(line) > 0 && start.Byte >= line[0].Start.Byte {
@@ -695,7 +703,7 @@
 
 		start, end := x.Span()
 		if debug {
-			fmt.Printf("post %T :%d:%d #%d :%d:%d #%d\n", x, start.Line, start.LineRune, start.Byte, end.Line, end.LineRune, end.Byte)
+			fmt.Fprintf(os.Stderr, "post %T :%d:%d #%d :%d:%d #%d\n", x, start.Line, start.LineRune, start.Byte, end.Line, end.LineRune, end.Byte)
 		}
 
 		// Do not assign suffix comments to end of line block or whole file.
diff --git a/modfile/rule.go b/modfile/rule.go
index 62af068..67914a8 100644
--- a/modfile/rule.go
+++ b/modfile/rule.go
@@ -5,7 +5,6 @@
 package modfile
 
 import (
-	"bytes"
 	"errors"
 	"fmt"
 	"path/filepath"
@@ -120,7 +119,7 @@
 		Syntax: fs,
 	}
 
-	var errs bytes.Buffer
+	var errs ErrorList
 	for _, x := range fs.Stmt {
 		switch x := x.(type) {
 		case *Line:
@@ -129,14 +128,22 @@
 		case *LineBlock:
 			if len(x.Token) > 1 {
 				if strict {
-					fmt.Fprintf(&errs, "%s:%d: unknown block type: %s\n", file, x.Start.Line, strings.Join(x.Token, " "))
+					errs = append(errs, Error{
+						Filename: file,
+						Pos:      x.Start,
+						Err:      fmt.Errorf("unknown block type: %s", strings.Join(x.Token, " ")),
+					})
 				}
 				continue
 			}
 			switch x.Token[0] {
 			default:
 				if strict {
-					fmt.Fprintf(&errs, "%s:%d: unknown block type: %s\n", file, x.Start.Line, strings.Join(x.Token, " "))
+					errs = append(errs, Error{
+						Filename: file,
+						Pos:      x.Start,
+						Err:      fmt.Errorf("unknown block type: %s", strings.Join(x.Token, " ")),
+					})
 				}
 				continue
 			case "module", "require", "exclude", "replace":
@@ -147,15 +154,15 @@
 		}
 	}
 
-	if errs.Len() > 0 {
-		return nil, errors.New(strings.TrimRight(errs.String(), "\n"))
+	if len(errs) > 0 {
+		return nil, errs
 	}
 	return f, nil
 }
 
 var GoVersionRE = lazyregexp.New(`^([1-9][0-9]*)\.(0|[1-9][0-9]*)$`)
 
-func (f *File) add(errs *bytes.Buffer, line *Line, verb string, args []string, fix VersionFixer, strict bool) {
+func (f *File) add(errs *ErrorList, line *Line, verb string, args []string, fix VersionFixer, strict bool) {
 	// If strict is false, this module is a dependency.
 	// We ignore all unknown directives as well as main-module-only
 	// directives like replace and exclude. It will work better for
@@ -171,60 +178,79 @@
 		}
 	}
 
+	wrapModPathError := func(modPath string, err error) {
+		*errs = append(*errs, Error{
+			Filename: f.Syntax.Name,
+			Pos:      line.Start,
+			ModPath:  modPath,
+			Verb:     verb,
+			Err:      err,
+		})
+	}
+	wrapError := func(err error) {
+		*errs = append(*errs, Error{
+			Filename: f.Syntax.Name,
+			Pos:      line.Start,
+			Err:      err,
+		})
+	}
+	errorf := func(format string, args ...interface{}) {
+		wrapError(fmt.Errorf(format, args...))
+	}
+
 	switch verb {
 	default:
-		fmt.Fprintf(errs, "%s:%d: unknown directive: %s\n", f.Syntax.Name, line.Start.Line, verb)
+		errorf("unknown directive: %s", verb)
 
 	case "go":
 		if f.Go != nil {
-			fmt.Fprintf(errs, "%s:%d: repeated go statement\n", f.Syntax.Name, line.Start.Line)
+			errorf("repeated go statement")
 			return
 		}
 		if len(args) != 1 || !GoVersionRE.MatchString(args[0]) {
-			fmt.Fprintf(errs, "%s:%d: usage: go 1.23\n", f.Syntax.Name, line.Start.Line)
+			errorf("usage: go 1.23")
 			return
 		}
 		f.Go = &Go{Syntax: line}
 		f.Go.Version = args[0]
 	case "module":
 		if f.Module != nil {
-			fmt.Fprintf(errs, "%s:%d: repeated module statement\n", f.Syntax.Name, line.Start.Line)
+			errorf("repeated module statement")
 			return
 		}
 		f.Module = &Module{Syntax: line}
 		if len(args) != 1 {
-
-			fmt.Fprintf(errs, "%s:%d: usage: module module/path\n", f.Syntax.Name, line.Start.Line)
+			errorf("usage: module module/path")
 			return
 		}
 		s, err := parseString(&args[0])
 		if err != nil {
-			fmt.Fprintf(errs, "%s:%d: invalid quoted string: %v\n", f.Syntax.Name, line.Start.Line, err)
+			errorf("invalid quoted string: %v", err)
 			return
 		}
 		f.Module.Mod = module.Version{Path: s}
 	case "require", "exclude":
 		if len(args) != 2 {
-			fmt.Fprintf(errs, "%s:%d: usage: %s module/path v1.2.3\n", f.Syntax.Name, line.Start.Line, verb)
+			errorf("usage: %s module/path v1.2.3", verb)
 			return
 		}
 		s, err := parseString(&args[0])
 		if err != nil {
-			fmt.Fprintf(errs, "%s:%d: invalid quoted string: %v\n", f.Syntax.Name, line.Start.Line, err)
+			errorf("invalid quoted string: %v", err)
 			return
 		}
 		v, err := parseVersion(verb, s, &args[1], fix)
 		if err != nil {
-			fmt.Fprintf(errs, "%s:%d: %v\n", f.Syntax.Name, line.Start.Line, err)
+			wrapError(err)
 			return
 		}
 		pathMajor, err := modulePathMajor(s)
 		if err != nil {
-			fmt.Fprintf(errs, "%s:%d: %v\n", f.Syntax.Name, line.Start.Line, err)
+			wrapError(err)
 			return
 		}
 		if err := module.CheckPathMajor(v, pathMajor); err != nil {
-			fmt.Fprintf(errs, "%s:%d: %v\n", f.Syntax.Name, line.Start.Line, &Error{Verb: verb, ModPath: s, Err: err})
+			wrapModPathError(s, err)
 			return
 		}
 		if verb == "require" {
@@ -245,55 +271,55 @@
 			arrow = 1
 		}
 		if len(args) < arrow+2 || len(args) > arrow+3 || args[arrow] != "=>" {
-			fmt.Fprintf(errs, "%s:%d: usage: %s module/path [v1.2.3] => other/module v1.4\n\t or %s module/path [v1.2.3] => ../local/directory\n", f.Syntax.Name, line.Start.Line, verb, verb)
+			errorf("usage: %s module/path [v1.2.3] => other/module v1.4\n\t or %s module/path [v1.2.3] => ../local/directory", verb, verb)
 			return
 		}
 		s, err := parseString(&args[0])
 		if err != nil {
-			fmt.Fprintf(errs, "%s:%d: invalid quoted string: %v\n", f.Syntax.Name, line.Start.Line, err)
+			errorf("invalid quoted string: %v", err)
 			return
 		}
 		pathMajor, err := modulePathMajor(s)
 		if err != nil {
-			fmt.Fprintf(errs, "%s:%d: %v\n", f.Syntax.Name, line.Start.Line, err)
+			wrapModPathError(s, err)
 			return
 		}
 		var v string
 		if arrow == 2 {
 			v, err = parseVersion(verb, s, &args[1], fix)
 			if err != nil {
-				fmt.Fprintf(errs, "%s:%d: %v\n", f.Syntax.Name, line.Start.Line, err)
+				wrapError(err)
 				return
 			}
 			if err := module.CheckPathMajor(v, pathMajor); err != nil {
-				fmt.Fprintf(errs, "%s:%d: %v\n", f.Syntax.Name, line.Start.Line, &Error{Verb: verb, ModPath: s, Err: err})
+				wrapModPathError(s, err)
 				return
 			}
 		}
 		ns, err := parseString(&args[arrow+1])
 		if err != nil {
-			fmt.Fprintf(errs, "%s:%d: invalid quoted string: %v\n", f.Syntax.Name, line.Start.Line, err)
+			errorf("invalid quoted string: %v", err)
 			return
 		}
 		nv := ""
 		if len(args) == arrow+2 {
 			if !IsDirectoryPath(ns) {
-				fmt.Fprintf(errs, "%s:%d: replacement module without version must be directory path (rooted or starting with ./ or ../)\n", f.Syntax.Name, line.Start.Line)
+				errorf("replacement module without version must be directory path (rooted or starting with ./ or ../)")
 				return
 			}
 			if filepath.Separator == '/' && strings.Contains(ns, `\`) {
-				fmt.Fprintf(errs, "%s:%d: replacement directory appears to be Windows path (on a non-windows system)\n", f.Syntax.Name, line.Start.Line)
+				errorf("replacement directory appears to be Windows path (on a non-windows system)")
 				return
 			}
 		}
 		if len(args) == arrow+3 {
 			nv, err = parseVersion(verb, ns, &args[arrow+2], fix)
 			if err != nil {
-				fmt.Fprintf(errs, "%s:%d: %v\n", f.Syntax.Name, line.Start.Line, err)
+				wrapError(err)
 				return
 			}
 			if IsDirectoryPath(ns) {
-				fmt.Fprintf(errs, "%s:%d: replacement module directory path %q cannot have version\n", f.Syntax.Name, line.Start.Line, ns)
+				errorf("replacement module directory path %q cannot have version", ns)
 				return
 			}
 		}
@@ -405,14 +431,42 @@
 	return t, nil
 }
 
+type ErrorList []Error
+
+func (e ErrorList) Error() string {
+	errStrs := make([]string, len(e))
+	for i, err := range e {
+		errStrs[i] = err.Error()
+	}
+	return strings.Join(errStrs, "\n")
+}
+
 type Error struct {
-	Verb    string
-	ModPath string
-	Err     error
+	Filename string
+	Pos      Position
+	Verb     string
+	ModPath  string
+	Err      error
 }
 
 func (e *Error) Error() string {
-	return fmt.Sprintf("%s %s: %v", e.Verb, e.ModPath, e.Err)
+	var pos string
+	if e.Pos.LineRune > 1 {
+		// Don't print LineRune if it's 1 (beginning of line).
+		// It's always 1 except in scanner errors, which are rare.
+		pos = fmt.Sprintf("%s:%d:%d: ", e.Filename, e.Pos.Line, e.Pos.LineRune)
+	} else if e.Pos.Line > 0 {
+		pos = fmt.Sprintf("%s:%d: ", e.Filename, e.Pos.Line)
+	} else if e.Filename != "" {
+		pos = fmt.Sprintf("%s: ", e.Filename)
+	}
+
+	var directive string
+	if e.ModPath != "" {
+		directive = fmt.Sprintf("%s %s: ", e.Verb, e.ModPath)
+	}
+
+	return pos + directive + e.Err.Error()
 }
 
 func (e *Error) Unwrap() error { return e.Err }