cue/load: allow file overlays

This will avoid the need to write intermediate
files if these are not needed in the final result,
such as in generation pipelines.

Change-Id: I43d417ebcfa5c9b24704a441bf05dc5a79d6e4dd
Reviewed-on: https://cue-review.googlesource.com/c/cue/+/2369
Reviewed-by: Marcel van Lohuizen <mpvl@golang.org>
diff --git a/cue/load/config.go b/cue/load/config.go
index b2b7542..46ff754 100644
--- a/cue/load/config.go
+++ b/cue/load/config.go
@@ -21,6 +21,7 @@
 
 	"cuelang.org/go/cue/build"
 	"cuelang.org/go/cue/errors"
+	"cuelang.org/go/cue/token"
 )
 
 const (
@@ -113,6 +114,16 @@
 	// This is mostly used for bootstrapping.
 	StdRoot string
 
+	// Overlay provides a mapping of absolute file paths to file contents.
+	// If the file  with the given path already exists, the parser will use the
+	// alternative file contents provided by the map.
+	//
+	// Overlays provide incomplete support for when a given file doesn't
+	// already exist on disk. See the package doc above for more details.
+	//
+	// If the value must be of type string, []byte, io.Reader, or *ast.File.
+	Overlay map[string]Source
+
 	fileSystem
 }
 
@@ -122,10 +133,19 @@
 	return i
 }
 
-func (c Config) newErrInstance(m *match, path string, err errors.Error) *build.Instance {
+func (c Config) newErrInstance(m *match, path string, err error) *build.Instance {
 	i := c.Context.NewInstance(path, nil)
 	i.DisplayPath = path
-	i.ReportError(err)
+	switch x := err.(type) {
+	case errors.Error:
+		i.ReportError(x)
+	case errors.List:
+		for _, e := range x {
+			i.ReportError(e)
+		}
+	default:
+		i.ReportError(errors.Wrapf(err, token.NoPos, "instance"))
+	}
 	return i
 }
 
@@ -144,11 +164,17 @@
 		}
 	}
 
+	// TODO: we could populate this already with absolute file paths,
+	// but relative paths cannot be added. Consider what is reasonable.
+	if err := c.fileSystem.init(&c); err != nil {
+		return nil, err
+	}
+
 	// TODO: determine root on a package basis. Maybe we even need a
 	// pkgname.cue.mod
 	// Look to see if there is a cue.mod.
 	if c.modRoot == "" {
-		abs, err := findRoot(c.Dir)
+		abs, err := c.findRoot(c.Dir)
 		if err != nil {
 			// Not using modules: only consider the current directory.
 			c.modRoot = c.Dir
@@ -165,20 +191,21 @@
 
 	if c.cache == "" {
 		c.cache = filepath.Join(home(), defaultDir)
-		// os.MkdirAll(c.Cache, 0755) // TODO: tools task
 	}
 
 	return &c, nil
 }
 
-func findRoot(dir string) (string, error) {
+func (c Config) findRoot(dir string) (string, error) {
+	fs := &c.fileSystem
+
 	absDir, err := filepath.Abs(dir)
 	if err != nil {
 		return "", err
 	}
 	abs := absDir
 	for {
-		info, err := os.Stat(filepath.Join(abs, modFile))
+		info, err := fs.stat(filepath.Join(abs, modFile))
 		if err == nil && !info.IsDir() {
 			return abs, nil
 		}
@@ -190,7 +217,7 @@
 	}
 	abs = absDir
 	for {
-		info, err := os.Stat(filepath.Join(abs, pkgDir))
+		info, err := fs.stat(filepath.Join(abs, pkgDir))
 		if err == nil && info.IsDir() {
 			return abs, nil
 		}
diff --git a/cue/load/fs.go b/cue/load/fs.go
index 0898e5f..abf830f 100644
--- a/cue/load/fs.go
+++ b/cue/load/fs.go
@@ -15,97 +15,126 @@
 package load
 
 import (
+	"bytes"
+	"go/ast"
 	"io"
 	"io/ioutil"
 	"os"
 	"path/filepath"
+	"sort"
 	"strings"
+	"time"
+
+	"cuelang.org/go/cue/errors"
+	"cuelang.org/go/cue/token"
 )
 
-// TODO: remove this file if we know we don't need it.
+type overlayFile struct {
+	basename string
+	contents []byte
+	file     *ast.File
+	modtime  time.Time
+	isDir    bool
+}
+
+func (f *overlayFile) Name() string       { return f.basename }
+func (f *overlayFile) Size() int64        { return int64(len(f.contents)) }
+func (f *overlayFile) Mode() os.FileMode  { return 0644 }
+func (f *overlayFile) ModTime() time.Time { return f.modtime }
+func (f *overlayFile) IsDir() bool        { return f.isDir }
+func (f *overlayFile) Sys() interface{}   { return nil }
 
 // A fileSystem specifies the supporting context for a build.
 type fileSystem struct {
-	// By default, Import uses the operating system's file system calls
-	// to read directories and files. To read from other sources,
-	// callers can set the following functions. They all have default
-	// behaviors that use the local file system, so clients need only set
-	// the functions whose behaviors they wish to change.
-
-	// JoinPath joins the sequence of path fragments into a single path.
-	// If JoinPath is nil, Import uses filepath.Join.
-	JoinPath func(elem ...string) string
-
-	// SplitPathList splits the path list into a slice of individual paths.
-	// If SplitPathList is nil, Import uses filepath.SplitList.
-	SplitPathList func(list string) []string
-
-	// IsAbsPath reports whether path is an absolute path.
-	// If IsAbsPath is nil, Import uses filepath.IsAbs.
-	IsAbsPath func(path string) bool
-
-	// IsDir reports whether the path names a directory.
-	// If IsDir is nil, Import calls os.Stat and uses the result's IsDir method.
-	IsDir func(path string) bool
-
-	// HasSubdir reports whether dir is a subdirectory of
-	// (perhaps multiple levels below) root.
-	// If so, HasSubdir sets rel to a slash-separated path that
-	// can be joined to root to produce a path equivalent to dir.
-	// If HasSubdir is nil, Import uses an implementation built on
-	// filepath.EvalSymlinks.
-	HasSubdir func(root, dir string) (rel string, ok bool)
-
-	// ReadDir returns a slice of os.FileInfo, sorted by Name,
-	// describing the content of the named directory.
-	// If ReadDir is nil, Import uses ioutil.ReadDir.
-	ReadDir func(dir string) ([]os.FileInfo, error)
-
-	// OpenFile opens a file (not a directory) for reading.
-	// If OpenFile is nil, Import uses os.Open.
-	OpenFile func(path string) (io.ReadCloser, error)
+	overlayDirs map[string]map[string]*overlayFile
+	cwd         string
 }
 
-// JoinPath calls ctxt.JoinPath (if not nil) or else filepath.Join.
-func (ctxt *fileSystem) joinPath(elem ...string) string {
-	if f := ctxt.JoinPath; f != nil {
-		return f(elem...)
+func (fs *fileSystem) getDir(dir string, create bool) map[string]*overlayFile {
+	dir = filepath.Clean(dir)
+	m, ok := fs.overlayDirs[dir]
+	if !ok {
+		m = map[string]*overlayFile{}
+		fs.overlayDirs[dir] = m
 	}
+	return m
+}
+
+func (fs *fileSystem) init(c *Config) error {
+	fs.cwd = c.Dir
+
+	overlay := c.Overlay
+	fs.overlayDirs = map[string]map[string]*overlayFile{}
+
+	// Organize overlay
+	for filename, src := range overlay {
+		if !strings.HasSuffix(filename, ".cue") {
+			return errors.Newf(token.NoPos, "overlay file %s not a .cue file", filename)
+		}
+
+		// TODO: do we need to further clean the path or check that the
+		// specified files are within the root/ absolute files?
+		dir, base := filepath.Split(filename)
+		m := fs.getDir(dir, true)
+
+		b, err := src.contents()
+		if err != nil {
+			return err
+		}
+		m[base] = &overlayFile{
+			basename: base,
+			contents: b,
+			modtime:  time.Now(),
+		}
+
+		for {
+			prevdir := dir
+			dir, base = filepath.Split(filepath.Dir(dir))
+			if dir == prevdir || dir == "" {
+				break
+			}
+			m := fs.getDir(dir, true)
+			if m[base] == nil {
+				m[base] = &overlayFile{
+					basename: base,
+					modtime:  time.Now(),
+					isDir:    true,
+				}
+			}
+		}
+	}
+	return nil
+}
+
+func (fs *fileSystem) joinPath(elem ...string) string {
 	return filepath.Join(elem...)
 }
 
-// splitPathList calls ctxt.SplitPathList (if not nil) or else filepath.SplitList.
-func (ctxt *fileSystem) splitPathList(s string) []string {
-	if f := ctxt.SplitPathList; f != nil {
-		return f(s)
-	}
+func (fs *fileSystem) splitPathList(s string) []string {
 	return filepath.SplitList(s)
 }
 
-// isAbsPath calls ctxt.IsAbsPath (if not nil) or else filepath.IsAbs.
-func (ctxt *fileSystem) isAbsPath(path string) bool {
-	if f := ctxt.IsAbsPath; f != nil {
-		return f(path)
-	}
+func (fs *fileSystem) isAbsPath(path string) bool {
 	return filepath.IsAbs(path)
 }
 
-// isDir calls ctxt.IsDir (if not nil) or else uses os.Stat.
-func (ctxt *fileSystem) isDir(path string) bool {
-	if f := ctxt.IsDir; f != nil {
-		return f(path)
+func (fs *fileSystem) makeAbs(path string) string {
+	if fs.isAbsPath(path) {
+		return path
+	}
+	return filepath.Clean(filepath.Join(fs.cwd, path))
+}
+
+func (fs *fileSystem) isDir(path string) bool {
+	path = fs.makeAbs(path)
+	if fs.getDir(path, false) != nil {
+		return true
 	}
 	fi, err := os.Stat(path)
 	return err == nil && fi.IsDir()
 }
 
-// hasSubdir calls ctxt.HasSubdir (if not nil) or else uses
-// the local file system to answer the question.
-func (ctxt *fileSystem) hasSubdir(root, dir string) (rel string, ok bool) {
-	if f := ctxt.HasSubdir; f != nil {
-		return f(root, dir)
-	}
-
+func (fs *fileSystem) hasSubdir(root, dir string) (rel string, ok bool) {
 	// Try using paths we received.
 	if rel, ok = hasSubdir(root, dir); ok {
 		return
@@ -139,35 +168,127 @@
 	return filepath.ToSlash(dir[len(root):]), true
 }
 
-// ReadDir calls ctxt.ReadDir (if not nil) or else ioutil.ReadDir.
-func (ctxt *fileSystem) readDir(path string) ([]os.FileInfo, error) {
-	if f := ctxt.ReadDir; f != nil {
-		return f(path)
+func (fs *fileSystem) readDir(path string) ([]os.FileInfo, errors.Error) {
+	path = fs.makeAbs(path)
+	m := fs.getDir(path, false)
+	items, err := ioutil.ReadDir(path)
+	if err != nil {
+		if !os.IsNotExist(err) || m == nil {
+			return nil, errors.Wrapf(err, token.NoPos, "readDir")
+		}
 	}
-	return ioutil.ReadDir(path)
+	if m != nil {
+		done := map[string]bool{}
+		for i, fi := range items {
+			done[fi.Name()] = true
+			if o := m[fi.Name()]; o != nil {
+				items[i] = o
+			}
+		}
+		for _, o := range m {
+			if !done[o.Name()] {
+				items = append(items, o)
+			}
+		}
+		sort.Slice(items, func(i, j int) bool {
+			return items[i].Name() < items[j].Name()
+		})
+	}
+	return items, nil
 }
 
-// openFile calls ctxt.OpenFile (if not nil) or else os.Open.
-func (ctxt *fileSystem) openFile(path string) (io.ReadCloser, error) {
-	if fn := ctxt.OpenFile; fn != nil {
-		return fn(path)
+func (fs *fileSystem) getOverlay(path string) *overlayFile {
+	dir, base := filepath.Split(path)
+	if m := fs.getDir(dir, false); m != nil {
+		return m[base]
+	}
+	return nil
+}
+
+func (fs *fileSystem) stat(path string) (os.FileInfo, errors.Error) {
+	path = fs.makeAbs(path)
+	if fi := fs.getOverlay(path); fi != nil {
+		return fi, nil
+	}
+	fi, err := os.Stat(path)
+	if err != nil {
+		return nil, errors.Wrapf(err, token.NoPos, "stat")
+	}
+	return fi, nil
+}
+
+func (fs *fileSystem) lstat(path string) (os.FileInfo, errors.Error) {
+	path = fs.makeAbs(path)
+	if fi := fs.getOverlay(path); fi != nil {
+		return fi, nil
+	}
+	fi, err := os.Lstat(path)
+	if err != nil {
+		return nil, errors.Wrapf(err, token.NoPos, "stat")
+	}
+	return fi, nil
+}
+
+func (fs *fileSystem) openFile(path string) (io.ReadCloser, errors.Error) {
+	path = fs.makeAbs(path)
+	if fi := fs.getOverlay(path); fi != nil {
+		return ioutil.NopCloser(bytes.NewReader(fi.contents)), nil
 	}
 
 	f, err := os.Open(path)
 	if err != nil {
-		return nil, err // nil interface
+		return nil, errors.Wrapf(err, token.NoPos, "load")
 	}
 	return f, nil
 }
 
-// isFile determines whether path is a file by trying to open it.
-// It reuses openFile instead of adding another function to the
-// list in Context.
-func (ctxt *fileSystem) isFile(path string) bool {
-	f, err := ctxt.openFile(path)
+var skipDir = errors.Newf(token.NoPos, "skip directory")
+
+type walkFunc func(path string, info os.FileInfo, err errors.Error) errors.Error
+
+func (fs *fileSystem) walk(root string, f walkFunc) error {
+	fi, err := fs.lstat(root)
 	if err != nil {
-		return false
+		err = f(root, fi, err)
+	} else if !fi.IsDir() {
+		return errors.Newf(token.NoPos, "path %q is not a directory", root)
+	} else {
+		err = fs.walkRec(root, fi, f)
 	}
-	f.Close()
-	return true
+	if err == skipDir {
+		return nil
+	}
+	return err
+
+}
+
+func (fs *fileSystem) walkRec(path string, info os.FileInfo, f walkFunc) errors.Error {
+	if !info.IsDir() {
+		return f(path, info, nil)
+	}
+
+	dir, err := fs.readDir(path)
+	err1 := f(path, info, err)
+
+	// If err != nil, walk can't walk into this directory.
+	// err1 != nil means walkFn want walk to skip this directory or stop walking.
+	// Therefore, if one of err and err1 isn't nil, walk will return.
+	if err != nil || err1 != nil {
+		// The caller's behavior is controlled by the return value, which is decided
+		// by walkFn. walkFn may ignore err and return nil.
+		// If walkFn returns SkipDir, it will be handled by the caller.
+		// So walk should return whatever walkFn returns.
+		return err1
+	}
+
+	for _, info := range dir {
+		filename := fs.joinPath(path, info.Name())
+		err = fs.walkRec(filename, info, f)
+		if err != nil {
+			if !info.IsDir() || err != skipDir {
+				return err
+			}
+		}
+	}
+	return nil
 }
diff --git a/cue/load/import.go b/cue/load/import.go
index b5961b4..11f6a6e 100644
--- a/cue/load/import.go
+++ b/cue/load/import.go
@@ -17,7 +17,6 @@
 import (
 	"bytes"
 	"log"
-	"os"
 	pathpkg "path"
 	"path/filepath"
 	"sort"
@@ -150,10 +149,14 @@
 	}
 
 	for _, f := range p.CUEFiles {
-		if !filepath.IsAbs(f) {
-			f = filepath.Join(root, f)
+		if !ctxt.isAbsPath(f) {
+			f = ctxt.joinPath(root, f)
 		}
-		_ = p.AddFile(f, nil)
+		r, err := ctxt.openFile(f)
+		if err != nil {
+			p.ReportError(err)
+		}
+		_ = p.AddFile(f, r)
 	}
 	p.Complete()
 	return p
@@ -214,7 +217,7 @@
 		return nil
 	}
 	dir := ctxt.joinPath(srcDir, path)
-	info, err := os.Stat(filepath.Join(srcDir, path))
+	info, err := ctxt.stat(filepath.Join(srcDir, path))
 	if err == nil && info.IsDir() {
 		p.Dir = dir
 		return nil
@@ -330,7 +333,7 @@
 
 	match, data, filename, err := matchFile(fp.c, dir, name, true, fp.allFiles, fp.allTags)
 	if err != nil {
-		return badFile(errors.Wrapf(err, pos, "no match"))
+		return badFile(err)
 	}
 	if !match {
 		if ext == cueSuffix {
@@ -341,10 +344,10 @@
 		return false // don't mark as added
 	}
 
-	pf, err := parser.ParseFile(filename, data, parser.ImportsOnly, parser.ParseComments)
-	if err != nil {
+	pf, perr := parser.ParseFile(filename, data, parser.ImportsOnly, parser.ParseComments)
+	if perr != nil {
 		// should always be an errors.List, but just in case.
-		switch x := err.(type) {
+		switch x := perr.(type) {
 		case errors.List:
 			for _, e := range x {
 				badFile(e)
@@ -352,7 +355,7 @@
 		case errors.Error:
 			badFile(x)
 		default:
-			badFile(errors.Wrapf(err, token.NoPos, "error adding file"))
+			badFile(errors.Wrapf(err, token.NoPos, "add failed"))
 		}
 		return true
 	}
diff --git a/cue/load/loader.go b/cue/load/loader.go
index 9060a1c..4753763 100644
--- a/cue/load/loader.go
+++ b/cue/load/loader.go
@@ -20,7 +20,6 @@
 //    - go/build
 
 import (
-	"os"
 	pathpkg "path"
 	"path/filepath"
 	"strings"
@@ -115,7 +114,7 @@
 		if !filepath.IsAbs(file) {
 			path = filepath.Join(cfg.Dir, file)
 		}
-		fi, err := os.Stat(path)
+		fi, err := cfg.fileSystem.stat(path)
 		if err != nil {
 			return cfg.newErrInstance(nil, path,
 				errors.Wrapf(err, pos, "could not find dir %s", path))
@@ -146,10 +145,15 @@
 	// }
 
 	for _, f := range pkg.CUEFiles {
-		if !filepath.IsAbs(f) {
-			f = filepath.Join(cfg.Dir, f)
+		fs := &l.cfg.fileSystem
+		if !fs.isAbsPath(f) {
+			f = fs.joinPath(cfg.Dir, f)
 		}
-		_ = pkg.AddFile(f, nil)
+		r, err := fs.openFile(f)
+		if err != nil {
+			pkg.ReportError(err)
+		}
+		_ = pkg.AddFile(f, r)
 	}
 
 	pkg.Local = true
diff --git a/cue/load/loader_test.go b/cue/load/loader_test.go
index ee0884f..18f709e 100644
--- a/cue/load/loader_test.go
+++ b/cue/load/loader_test.go
@@ -22,8 +22,11 @@
 	"strconv"
 	"strings"
 	"testing"
+	"unicode"
 
+	"cuelang.org/go/cue"
 	build "cuelang.org/go/cue/build"
+	"cuelang.org/go/cue/format"
 	"cuelang.org/go/internal/str"
 )
 
@@ -122,3 +125,52 @@
 	}
 	return b.String()
 }
+
+func TestOverlays(t *testing.T) {
+	cwd, _ := os.Getwd()
+	abs := func(path string) string {
+		return filepath.Join(cwd, path)
+	}
+	c := &Config{
+		Overlay: map[string]Source{
+			abs("dir/top.cue"): FromBytes([]byte(`
+			   package top
+			   msg: "Hello"
+			`)),
+			abs("dir/b/foo.cue"): FromString(`
+			   package foo
+
+			   a: <= 5
+			`),
+			abs("dir/b/bar.cue"): FromString(`
+			   package foo
+
+			   a: >= 5
+			`),
+		},
+	}
+	want := []string{
+		`{msg:"Hello"}`,
+		`{a:5}`,
+	}
+	rmSpace := func(r rune) rune {
+		if unicode.IsSpace(r) {
+			return -1
+		}
+		return r
+	}
+	for i, inst := range cue.Build(Instances([]string{"./dir/..."}, c)) {
+		if inst.Err != nil {
+			t.Error(inst.Err)
+			continue
+		}
+		b, err := format.Node(inst.Value().Syntax())
+		if err != nil {
+			t.Error(err)
+			continue
+		}
+		if got := string(bytes.Map(rmSpace, b)); got != want[i] {
+			t.Errorf("%s: got %s; want %s", inst.Dir, got, want)
+		}
+	}
+}
diff --git a/cue/load/match.go b/cue/load/match.go
index fb00fa6..5f8af37 100644
--- a/cue/load/match.go
+++ b/cue/load/match.go
@@ -16,9 +16,11 @@
 
 import (
 	"bytes"
-	"fmt"
 	"strings"
 	"unicode"
+
+	"cuelang.org/go/cue/errors"
+	"cuelang.org/go/cue/token"
 )
 
 // matchFileTest reports whether the file with the given name in the given directory
@@ -40,7 +42,7 @@
 // considers text until the first non-comment.
 // If allTags is non-nil, matchFile records any encountered build tag
 // by setting allTags[tag] = true.
-func matchFile(cfg *Config, dir, name string, returnImports, allFiles bool, allTags map[string]bool) (match bool, data []byte, filename string, err error) {
+func matchFile(cfg *Config, dir, name string, returnImports, allFiles bool, allTags map[string]bool) (match bool, data []byte, filename string, err errors.Error) {
 	if strings.HasPrefix(name, "_") ||
 		strings.HasPrefix(name, ".") {
 		return
@@ -73,7 +75,7 @@
 	}
 	f.Close()
 	if err != nil {
-		err = fmt.Errorf("read %s: %v", filename, err)
+		err = errors.Newf(token.NoPos, "read %s: %v", filename, err)
 		return
 	}
 
diff --git a/cue/load/match_test.go b/cue/load/match_test.go
index 3dbab93..f2876d5 100644
--- a/cue/load/match_test.go
+++ b/cue/load/match_test.go
@@ -16,8 +16,9 @@
 
 import (
 	"io"
+	"os"
+	"path/filepath"
 	"reflect"
-	"strings"
 	"testing"
 )
 
@@ -116,27 +117,25 @@
 	match bool
 }{
 	{defCfg, "foo.cue", "", true},
-	{defCfg, "foo.cue", "// +build enable\n\npackage foo\n", false},
+	{defCfg, "a/b/c/foo.cue", "// +build enable\n\npackage foo\n", false},
 	{defCfg, "foo.cue", "// +build !enable\n\npackage foo\n", true},
 	{defCfg, "foo1.cue", "// +build linux\n\npackage foo\n", false},
 	{defCfg, "foo.badsuffix", "", false},
-	{cfg, "foo.cue", "// +build enable\n\npackage foo\n", true},
+	{cfg, "a/b/c/d/foo.cue", "// +build enable\n\npackage foo\n", true},
 	{cfg, "foo.cue", "// +build !enable\n\npackage foo\n", false},
 }
 
 func TestMatchFile(t *testing.T) {
+	cwd, _ := os.Getwd()
+	abs := func(path string) string {
+		return filepath.Join(cwd, path)
+	}
 	for _, tt := range matchFileTests {
-		ctxt := &tt.cfg.fileSystem
-		ctxt.OpenFile = func(path string) (r io.ReadCloser, err error) {
-			if path != "x+"+tt.name {
-				t.Fatalf("OpenFile asked for %q, expected %q", path, "x+"+tt.name)
-			}
-			return &readNopCloser{strings.NewReader(tt.data)}, nil
-		}
-		ctxt.JoinPath = func(elem ...string) string {
-			return strings.Join(elem, "+")
-		}
-		match, err := matchFileTest(tt.cfg, "x", tt.name)
+		cfg := tt.cfg
+		cfg.Overlay = map[string]Source{abs(tt.name): FromString(tt.data)}
+		cfg, _ = cfg.complete()
+
+		match, err := matchFileTest(cfg, "", tt.name)
 		if match != tt.match || err != nil {
 			t.Fatalf("MatchFile(%q) = %v, %v, want %v, nil", tt.name, match, err, tt.match)
 		}
diff --git a/cue/load/read.go b/cue/load/read.go
index 79f68b4..beb04d8 100644
--- a/cue/load/read.go
+++ b/cue/load/read.go
@@ -16,16 +16,18 @@
 
 import (
 	"bufio"
-	"errors"
 	"io"
 	"unicode/utf8"
+
+	"cuelang.org/go/cue/errors"
+	"cuelang.org/go/cue/token"
 )
 
 type importReader struct {
 	b    *bufio.Reader
 	buf  []byte
 	peek byte
-	err  error
+	err  errors.Error
 	eof  bool
 	nerr int
 }
@@ -35,8 +37,8 @@
 }
 
 var (
-	errSyntax = errors.New("syntax error")
-	errNUL    = errors.New("unexpected NUL in input")
+	errSyntax = errors.Newf(token.NoPos, "syntax error") // TODO: remove
+	errNUL    = errors.Newf(token.NoPos, "unexpected NUL in input")
 )
 
 // syntaxError records a syntax error, but only if an I/O error has not already been recorded.
@@ -60,7 +62,7 @@
 		if err == io.EOF {
 			r.eof = true
 		} else if r.err == nil {
-			r.err = err
+			r.err = errors.Wrapf(err, token.NoPos, "readByte")
 		}
 		c = 0
 	}
@@ -208,7 +210,7 @@
 
 // readComments is like ioutil.ReadAll, except that it only reads the leading
 // block of comments in the file.
-func readComments(f io.Reader) ([]byte, error) {
+func readComments(f io.Reader) ([]byte, errors.Error) {
 	r := &importReader{b: bufio.NewReader(f)}
 	r.peekByte(true)
 	if r.err == nil && !r.eof {
@@ -220,7 +222,7 @@
 
 // readImports is like ioutil.ReadAll, except that it expects a Go file as input
 // and stops reading the input once the imports have completed.
-func readImports(f io.Reader, reportSyntaxError bool, imports *[]string) ([]byte, error) {
+func readImports(f io.Reader, reportSyntaxError bool, imports *[]string) ([]byte, errors.Error) {
 	r := &importReader{b: bufio.NewReader(f)}
 
 	r.readKeyword("package")
diff --git a/cue/load/read_test.go b/cue/load/read_test.go
index aeb2b1d..372ebe8 100644
--- a/cue/load/read_test.go
+++ b/cue/load/read_test.go
@@ -18,6 +18,8 @@
 	"io"
 	"strings"
 	"testing"
+
+	"cuelang.org/go/cue/errors"
 )
 
 const quote = "`"
@@ -104,7 +106,7 @@
 	},
 }
 
-func testRead(t *testing.T, tests []readTest, read func(io.Reader) ([]byte, error)) {
+func testRead(t *testing.T, tests []readTest, read func(io.Reader) ([]byte, errors.Error)) {
 	for i, tt := range tests {
 		var in, testOut string
 		j := strings.Index(tt.in, "ℙ")
@@ -141,7 +143,9 @@
 }
 
 func TestReadImports(t *testing.T) {
-	testRead(t, readImportsTests, func(r io.Reader) ([]byte, error) { return readImports(r, true, nil) })
+	testRead(t, readImportsTests, func(r io.Reader) ([]byte, errors.Error) {
+		return readImports(r, true, nil)
+	})
 }
 
 func TestReadComments(t *testing.T) {
@@ -217,7 +221,9 @@
 
 func TestReadFailures(t *testing.T) {
 	// Errors should be reported (true arg to readImports).
-	testRead(t, readFailuresTests, func(r io.Reader) ([]byte, error) { return readImports(r, true, nil) })
+	testRead(t, readFailuresTests, func(r io.Reader) ([]byte, errors.Error) {
+		return readImports(r, true, nil)
+	})
 }
 
 func TestReadFailuresIgnored(t *testing.T) {
@@ -232,5 +238,7 @@
 			tt.err = ""
 		}
 	}
-	testRead(t, tests, func(r io.Reader) ([]byte, error) { return readImports(r, false, nil) })
+	testRead(t, tests, func(r io.Reader) ([]byte, errors.Error) {
+		return readImports(r, false, nil)
+	})
 }
diff --git a/cue/load/search.go b/cue/load/search.go
index af48a0b..da43ad5 100644
--- a/cue/load/search.go
+++ b/cue/load/search.go
@@ -15,7 +15,8 @@
 package load
 
 import (
-	"fmt" // TODO: remove this usage
+	// TODO: remove this usage
+
 	"os"
 	"path"
 	"path/filepath"
@@ -23,6 +24,7 @@
 	"strings"
 
 	build "cuelang.org/go/cue/build"
+	"cuelang.org/go/cue/errors"
 	"cuelang.org/go/cue/token"
 )
 
@@ -94,7 +96,7 @@
 	// 			return nil
 	// 		}
 	// 		if !want {
-	// 			return filepath.SkipDir
+	// 			return skipDir
 	// 		}
 
 	// 		if have[name] {
@@ -148,7 +150,7 @@
 
 	if c.modRoot != "" {
 		if !hasFilepathPrefix(root, c.modRoot) {
-			m.Err = fmt.Errorf(
+			m.Err = errors.Newf(token.NoPos,
 				"cue: pattern %s refers to dir %s, outside module root %s",
 				pattern, root, c.modRoot)
 			return m
@@ -157,12 +159,12 @@
 
 	pkgDir := filepath.Join(root, "pkg")
 
-	_ = filepath.Walk(root, func(path string, fi os.FileInfo, err error) error {
+	_ = c.fileSystem.walk(root, func(path string, fi os.FileInfo, err errors.Error) errors.Error {
 		if err != nil || !fi.IsDir() {
 			return nil
 		}
 		if path == pkgDir {
-			return filepath.SkipDir
+			return skipDir
 		}
 
 		top := path == root
@@ -171,13 +173,13 @@
 		_, elem := filepath.Split(path)
 		dot := strings.HasPrefix(elem, ".") && elem != "." && elem != ".."
 		if dot || strings.HasPrefix(elem, "_") || (elem == "testdata" && !top) {
-			return filepath.SkipDir
+			return skipDir
 		}
 
 		if !top {
 			// Ignore other modules found in subdirectories.
-			if _, err := os.Stat(filepath.Join(path, modFile)); err == nil {
-				return filepath.SkipDir
+			if _, err := c.fileSystem.stat(filepath.Join(path, modFile)); err == nil {
+				return skipDir
 			}
 		}
 
@@ -300,7 +302,7 @@
 	for _, m := range matches {
 		if len(m.Pkgs) == 0 {
 			m.Err =
-				fmt.Errorf("cue: %q matched no packages\n", m.Pattern)
+				errors.Newf(token.NoPos, "cue: %q matched no packages\n", m.Pattern)
 		}
 	}
 }
diff --git a/cue/load/source.go b/cue/load/source.go
new file mode 100644
index 0000000..9514f2a
--- /dev/null
+++ b/cue/load/source.go
@@ -0,0 +1,60 @@
+// Copyright 2019 CUE Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package load
+
+import (
+	"cuelang.org/go/cue/ast"
+	"cuelang.org/go/cue/format"
+)
+
+// A Source represents file contents.
+type Source interface {
+	contents() ([]byte, error)
+}
+
+// FromString creates a Source from the given string.
+func FromString(s string) Source {
+	return stringSource(s)
+}
+
+// FromBytes creates a Source from the given bytes. The contents are not
+// copied and should not be modified.
+func FromBytes(b []byte) Source {
+	return bytesSource(b)
+}
+
+// FromFile creates a Source from the given *ast.File. The file should not be
+// modified. It is assumed the file is error-free.
+func FromFile(f *ast.File) Source {
+	return (*fileSource)(f)
+}
+
+type stringSource string
+
+func (s stringSource) contents() ([]byte, error) {
+	return []byte(s), nil
+}
+
+type bytesSource []byte
+
+func (s bytesSource) contents() ([]byte, error) {
+	return []byte(s), nil
+}
+
+type fileSource ast.File
+
+func (s *fileSource) contents() ([]byte, error) {
+	return format.Node((*ast.File)(s))
+}