cue/load: provide reason for exclusion of files

Fixes #741
Issue #52

Change-Id: I8b61262be1fec41cdafd5ff78ee096a6dd6893fd
Reviewed-on: https://cue-review.googlesource.com/c/cue/+/9682
Reviewed-by: CUE cueckoo <cueckoo@gmail.com>
Reviewed-by: Paul Jolly <paul@myitcv.org.uk>
diff --git a/cmd/cue/cmd/testdata/script/eval_hiddenfail.txt b/cmd/cue/cmd/testdata/script/eval_hiddenfail.txt
index c12d92f..d856bec 100644
--- a/cmd/cue/cmd/testdata/script/eval_hiddenfail.txt
+++ b/cmd/cue/cmd/testdata/script/eval_hiddenfail.txt
@@ -2,7 +2,8 @@
 cmp stderr expect-stderr
 
 -- expect-stderr --
-build constraints exclude all CUE files in . (ignored: .foo.cue)
+build constraints exclude all CUE files in .:
+    .foo.cue: filename starts with a '.'
 -- .foo.cue --
 package foo
 
diff --git a/cmd/cue/cmd/testdata/script/issue174.txt b/cmd/cue/cmd/testdata/script/issue174.txt
index a611481..a5f1cbd 100644
--- a/cmd/cue/cmd/testdata/script/issue174.txt
+++ b/cmd/cue/cmd/testdata/script/issue174.txt
@@ -1,7 +1,8 @@
 ! cue export ./issue174
 cmp stderr expect-stderr
 -- expect-stderr --
-build constraints exclude all CUE files in ./issue174 (ignored: issue174/issue174.cue)
+build constraints exclude all CUE files in ./issue174:
+    issue174/issue174.cue: no package name
 -- issue174/issue174.cue --
 import 'foo'
 
diff --git a/cue/build/file.go b/cue/build/file.go
index 7a5d6d7..7b22d2e 100644
--- a/cue/build/file.go
+++ b/cue/build/file.go
@@ -14,6 +14,8 @@
 
 package build
 
+import "cuelang.org/go/cue/errors"
+
 // A File represents a file that is part of the build process.
 type File struct {
 	Filename string `json:"filename"`
@@ -23,7 +25,8 @@
 	Form           Form              `json:"form,omitempty"`
 	Tags           map[string]string `json:"tags,omitempty"` // code=go
 
-	Source interface{} `json:"-"` // TODO: swap out with concrete type.
+	ExcludeReason errors.Error `json:"-"`
+	Source        interface{}  `json:"-"` // TODO: swap out with concrete type.
 }
 
 // A Encoding indicates a file format for representing a program.
diff --git a/cue/errors/errors.go b/cue/errors/errors.go
index e8e5a0e..476d586 100644
--- a/cue/errors/errors.go
+++ b/cue/errors/errors.go
@@ -212,6 +212,14 @@
 	}
 }
 
+func (e *wrapped) Is(target error) bool {
+	return Is(e.main, target)
+}
+
+func (e *wrapped) As(target interface{}) bool {
+	return As(e.main, target)
+}
+
 func (e *wrapped) Msg() (format string, args []interface{}) {
 	return e.main.Msg()
 }
diff --git a/cue/load/errors.go b/cue/load/errors.go
index 0b74316..541c94a 100644
--- a/cue/load/errors.go
+++ b/cue/load/errors.go
@@ -115,19 +115,20 @@
 	path := e.Package.DisplayPath
 
 	if len(e.Package.IgnoredFiles) > dummy {
+		b := strings.Builder{}
+		b.WriteString("build constraints exclude all CUE files in ")
+		b.WriteString(path)
+		b.WriteString(":")
 		// CUE files exist, but they were ignored due to build constraints.
-		msg := "build constraints exclude all CUE files in " + path + " (ignored: "
-		var files []string
-		for i, f := range e.Package.IgnoredFiles {
-			if i == 4 {
-				files = append(files[:4], "...")
-				break
+		for _, f := range e.Package.IgnoredFiles {
+			b.WriteString("\n    ")
+			b.WriteString(filepath.ToSlash(e.Package.RelPath(f)))
+			if f.ExcludeReason != nil {
+				b.WriteString(": ")
+				b.WriteString(f.ExcludeReason.Error())
 			}
-			files = append(files, filepath.ToSlash(e.Package.RelPath(f)))
 		}
-		msg += strings.Join(files, ", ")
-		msg += ")"
-		return msg
+		return b.String()
 	}
 	// if len(e.Package.TestCUEFiles) > 0 {
 	// 	// Test CUE files exist, but we're not interested in them.
diff --git a/cue/load/import.go b/cue/load/import.go
index 657bcad..1f43a3d 100644
--- a/cue/load/import.go
+++ b/cue/load/import.go
@@ -185,7 +185,8 @@
 				file, err := filetypes.ParseFile(f.Name(), filetypes.Input)
 				if err != nil {
 					p.UnknownFiles = append(p.UnknownFiles, &build.File{
-						Filename: f.Name(),
+						Filename:      f.Name(),
+						ExcludeReason: errors.Newf(token.NoPos, "unknown filetype"),
 					})
 					continue // skip unrecognized file types
 				}
@@ -356,21 +357,31 @@
 	// badFile := func(p *build.Instance, err errors.Error) bool {
 	badFile := func(err errors.Error) bool {
 		fp.err = errors.Append(fp.err, err)
+		file.ExcludeReason = fp.err
 		p.InvalidFiles = append(p.InvalidFiles, file)
 		return true
 	}
 
 	match, data, err := matchFile(fp.c, file, true, fp.allFiles, fp.allTags)
-	if err != nil {
+	switch {
+	case match:
+
+	case err == nil:
+		// Not a CUE file.
+		p.OrphanedFiles = append(p.OrphanedFiles, file)
+		return false
+
+	case !errors.Is(err, errExclude):
 		return badFile(err)
-	}
-	if !match {
-		if file.Encoding == build.CUE && file.Interpretation == "" {
+
+	default:
+		file.ExcludeReason = err
+		if file.Interpretation == "" {
 			p.IgnoredFiles = append(p.IgnoredFiles, file)
 		} else {
 			p.OrphanedFiles = append(p.OrphanedFiles, file)
 		}
-		return false // don't mark as added
+		return false
 	}
 
 	pf, perr := parser.ParseFile(fullPath, data, parser.ImportsOnly, parser.ParseComments)
@@ -379,7 +390,7 @@
 		return true
 	}
 
-	_, pkg, _ := internal.PackageInfo(pf)
+	_, pkg, pos := internal.PackageInfo(pf)
 	if pkg == "" {
 		pkg = "_"
 	}
@@ -405,15 +416,17 @@
 	case pkg != "_":
 
 	default:
+		file.ExcludeReason = excludeError{errors.Newf(pos, "no package name")}
 		p.IgnoredFiles = append(p.IgnoredFiles, file)
 		return false // don't mark as added
 	}
 
 	if !fp.c.AllCUEFiles {
-		if include, err := shouldBuildFile(pf, fp); !include {
-			if err != nil {
+		if err := shouldBuildFile(pf, fp); err != nil {
+			if !errors.Is(err, errExclude) {
 				fp.err = errors.Append(fp.err, err)
 			}
+			file.ExcludeReason = err
 			p.IgnoredFiles = append(p.IgnoredFiles, file)
 			return false
 		}
@@ -425,6 +438,8 @@
 			fp.firstFile = base
 		} else if pkg != p.PkgName {
 			if fp.ignoreOther {
+				file.ExcludeReason = excludeError{errors.Newf(pos,
+					"package is %s, want %s", pkg, p.PkgName)}
 				p.IgnoredFiles = append(p.IgnoredFiles, file)
 				return false
 			}
@@ -478,12 +493,16 @@
 		if fp.c.loader.cfg.Tests {
 			p.BuildFiles = append(p.BuildFiles, file)
 		} else {
+			file.ExcludeReason = excludeError{errors.Newf(pos,
+				"_test.cue files excluded in non-test mode")}
 			p.IgnoredFiles = append(p.IgnoredFiles, file)
 		}
 	case isTool:
 		if fp.c.loader.cfg.Tools {
 			p.BuildFiles = append(p.BuildFiles, file)
 		} else {
+			file.ExcludeReason = excludeError{errors.Newf(pos,
+				"_tool.cue files excluded in non-cmd mode")}
 			p.IgnoredFiles = append(p.IgnoredFiles, file)
 		}
 	default:
diff --git a/cue/load/loader_test.go b/cue/load/loader_test.go
index 43b9be2..2fe5f24 100644
--- a/cue/load/loader_test.go
+++ b/cue/load/loader_test.go
@@ -96,7 +96,8 @@
 		cfg:  dirCfg,
 		args: args("./anon"),
 		want: `
-err:    build constraints exclude all CUE files in ./anon (ignored: anon/anon.cue)
+err:    build constraints exclude all CUE files in ./anon:
+	anon/anon.cue: no package name
 path:   example.org/test/anon
 module: example.org/test
 root:   $CWD/testdata
@@ -154,7 +155,10 @@
 		cfg:  dirCfg,
 		args: args("example.org/test/hello:nonexist"),
 		want: `
-err:    build constraints exclude all CUE files in example.org/test/hello:nonexist (ignored: anon.cue, test.cue, hello/test.cue)
+err:    build constraints exclude all CUE files in example.org/test/hello:nonexist:
+    anon.cue: no package name
+    test.cue: package is test, want nonexist
+    hello/test.cue: package is test, want nonexist
 path:   example.org/test/hello:nonexist
 module: example.org/test
 root:   $CWD/testdata
@@ -247,7 +251,10 @@
 		},
 		args: args("./toolonly"),
 		want: `
-err:    build constraints exclude all CUE files in ./toolonly (ignored: anon.cue, test.cue, toolonly/foo_tool.cue)
+err:    build constraints exclude all CUE files in ./toolonly:
+    anon.cue: no package name
+    test.cue: package is test, want foo
+    toolonly/foo_tool.cue: _tool.cue files excluded in non-cmd mode
 path:   example.org/test/toolonly:foo
 module: example.org/test
 root:   $CWD/testdata
diff --git a/cue/load/match.go b/cue/load/match.go
index 4ff21b4..c58e057 100644
--- a/cue/load/match.go
+++ b/cue/load/match.go
@@ -24,6 +24,15 @@
 	"cuelang.org/go/cue/token"
 )
 
+var errExclude = errors.New("file rejected")
+
+type cueError = errors.Error
+type excludeError struct {
+	cueError
+}
+
+func (e excludeError) Is(err error) bool { return err == errExclude }
+
 // matchFile determines whether the file with the given name in the given directory
 // should be included in the package being constructed.
 // It returns the data read from the file.
@@ -42,7 +51,7 @@
 	}
 
 	if file.Encoding != build.CUE {
-		return
+		return false, nil, nil // not a CUE file, don't record.
 	}
 
 	if file.Filename == "-" {
@@ -52,32 +61,33 @@
 			return
 		}
 		file.Source = b
-		data = b
-		match = true // don't check shouldBuild for stdin
-		return
+		return true, b, nil // don't check shouldBuild for stdin
 	}
 
 	name := filepath.Base(file.Filename)
 	if !cfg.filesMode && strings.HasPrefix(name, ".") {
-		return
+		return false, nil, &excludeError{
+			errors.Newf(token.NoPos, "filename starts with a '.'"),
+		}
 	}
 
 	if strings.HasPrefix(name, "_") {
-		return
+		return false, nil, &excludeError{
+			errors.Newf(token.NoPos, "filename starts with a '_"),
+		}
 	}
 
 	f, err := cfg.fileSystem.openFile(file.Filename)
 	if err != nil {
-		return
+		return false, nil, err
 	}
 
 	data, err = readImports(f, false, nil)
 	f.Close()
 	if err != nil {
-		err = errors.Newf(token.NoPos, "read %s: %v", file.Filename, err)
-		return
+		return false, nil,
+			errors.Newf(token.NoPos, "read %s: %v", file.Filename, err)
 	}
 
-	match = true
-	return
+	return true, data, nil
 }
diff --git a/cue/load/tags.go b/cue/load/tags.go
index a9ab269..65ed142 100644
--- a/cue/load/tags.go
+++ b/cue/load/tags.go
@@ -305,22 +305,22 @@
 
 // shouldBuildFile determines whether a File should be included based on its
 // attributes.
-func shouldBuildFile(f *ast.File, fp *fileProcessor) (bool, errors.Error) {
+func shouldBuildFile(f *ast.File, fp *fileProcessor) errors.Error {
 	tags := fp.c.Tags
 
 	a, errs := getBuildAttr(f)
 	if errs != nil {
-		return false, errs
+		return errs
 	}
 	if a == nil {
-		return true, nil
+		return nil
 	}
 
 	_, body := a.Split()
 
 	expr, err := parser.ParseExpr("", body)
 	if err != nil {
-		return false, errors.Promote(err, "")
+		return errors.Promote(err, "")
 	}
 
 	tagMap := map[string]bool{}
@@ -331,9 +331,12 @@
 	c := checker{tags: tagMap, loader: fp.c.loader}
 	include := c.shouldInclude(expr)
 	if c.err != nil {
-		return false, c.err
+		return c.err
 	}
-	return include, nil
+	if !include {
+		return excludeError{errors.Newf(a.Pos(), "@if(%s) did not match", body)}
+	}
+	return nil
 }
 
 func getBuildAttr(f *ast.File) (*ast.Attribute, errors.Error) {