From fb207662679b85c7c633301feb1c06dc79851049 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oskar=20Segersv=C3=A4rd?= Date: Sun, 24 Aug 2025 19:09:09 +0200 Subject: [PATCH] Revisit imports --- ast/node.go | 10 +++ cmd/scrap/main.go | 33 +++++----- eval/env.go | 139 +++++++++++++++++++++++++--------------- eval/eval.go | 24 +++++-- eval/eval_test.go | 17 +++-- parser/parser.go | 29 +++++++++ parser/parser_test.go | 17 +++++ scanner/scanner.go | 4 +- scanner/scanner_test.go | 3 +- token/token.go | 7 ++ types/infer.go | 44 +++++++------ types/infer_test.go | 73 +++++++++++++++++++-- yards/cache.go | 13 +++- yards/cache_test.go | 5 +- 14 files changed, 308 insertions(+), 110 deletions(-) diff --git a/ast/node.go b/ast/node.go index bce2e4b..c91a8dd 100644 --- a/ast/node.go +++ b/ast/node.go @@ -83,6 +83,14 @@ type WhereExpr struct { Val Expr } +type ImportExpr struct { + Pos token.Span + // Typically "sha256". + HashAlgo string + // Any literal, typically a byte-string. + Value Literal +} + func (b Ident) expr() {} func (b Literal) expr() {} func (b BinaryExpr) expr() {} @@ -95,6 +103,7 @@ func (b RecordExpr) expr() {} func (b AccessExpr) expr() {} func (b ListExpr) expr() {} func (b WhereExpr) expr() {} +func (b ImportExpr) expr() {} func span(start, end Expr) token.Span { return token.Span{ @@ -122,3 +131,4 @@ func (b RecordExpr) Span() token.Span { return b.Pos } func (b AccessExpr) Span() token.Span { return b.Pos } func (b ListExpr) Span() token.Span { return b.Pos } func (b *WhereExpr) Span() token.Span { return span(b.Expr, b.Val) } +func (b ImportExpr) Span() token.Span { return b.Pos } diff --git a/cmd/scrap/main.go b/cmd/scrap/main.go index 725a371..c797266 100644 --- a/cmd/scrap/main.go +++ b/cmd/scrap/main.go @@ -8,9 +8,6 @@ import ( "github.com/Victorystick/scrapscript" "github.com/Victorystick/scrapscript/eval" - "github.com/Victorystick/scrapscript/parser" - "github.com/Victorystick/scrapscript/token" - "github.com/Victorystick/scrapscript/types" "github.com/Victorystick/scrapscript/yards" ) @@ -44,20 +41,26 @@ func must[T any](val T, err error) T { return val } -func evaluate(args []string) { - fetcher := must(yards.NewDefaultCacheFetcher( +func makeEnv() *eval.Environment { + env := eval.NewEnvironment() + env.UseFetcher(must(yards.NewDefaultCacheFetcher( // Don't cache invalid scraps, but trust the local cache for now. yards.Validate( // TODO: make configurable yards.ByHttp("https://scraps.oseg.dev/")), - )) + ))) + return env +} +func evaluate(args []string) { input := must(io.ReadAll(os.Stdin)) - env := eval.NewEnvironment(fetcher) - val := must(env.Eval(input)) + env := makeEnv() + scrap := must(env.Read(input)) + val := must(env.Eval(scrap)) if len(args) >= 2 && args[0] == "apply" { - fn := must(env.Eval([]byte(args[1]))) + scrap = must(env.Read([]byte(args[1]))) + fn := must(env.Eval(scrap)) val = must(scrapscript.Call(fn, val)) } @@ -66,13 +69,7 @@ func evaluate(args []string) { func inferType(args []string) { input := must(io.ReadAll(os.Stdin)) - source := token.NewSource(input) - - se := must(parser.Parse(&source)) - str, err := types.Infer(se) - if err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } - fmt.Println(str) + env := makeEnv() + scrap := must(env.Read(input)) + fmt.Println(must(env.Infer(scrap))) } diff --git a/eval/env.go b/eval/env.go index 67d6788..9cfb36c 100644 --- a/eval/env.go +++ b/eval/env.go @@ -2,78 +2,84 @@ package eval import ( "crypto/sha256" - "encoding/base64" - "encoding/hex" "fmt" + "github.com/Victorystick/scrapscript/ast" "github.com/Victorystick/scrapscript/parser" "github.com/Victorystick/scrapscript/token" "github.com/Victorystick/scrapscript/types" "github.com/Victorystick/scrapscript/yards" ) +type Scrap struct { + expr ast.SourceExpr + typ types.TypeRef + value Value +} + +type Sha256Hash = [32]byte + type Environment struct { - reg types.Registry - vars Variables + fetcher yards.Fetcher + reg types.Registry + vars Variables + scraps map[Sha256Hash]*Scrap + evalImport EvalImport + inferImport types.InferImport } -func NewEnvironment(fetcher yards.Fetcher) *Environment { +func NewEnvironment() *Environment { env := &Environment{} env.vars = bindBuiltIns(&env.reg) - - if fetcher != nil { - // TODO: Don't inline this. :/ - env.vars["$sha256"] = BuiltInFunc{ - name: "$sha256", - // We must special-case import functions, since their type is dependent - // on their returned value. - typ: env.reg.Func(types.BytesRef, types.NeverRef), - fn: func(v Value) (Value, error) { - bs, ok := v.(Bytes) - if !ok { - return nil, fmt.Errorf("cannot import non-bytes %s", v) - } - - // Must convert from `eval.Byte` to `[]byte`. - hash := []byte(bs) - - // Funnily enough; any lower-cased, hex-encoded sha256 hash can be parsed - // as base64. Users reading the official documentation at - // https://scrapscript.org/guide may be frustrated if this doesn't work. - // We detect this and convert back via base64 to the original hex string. - var err error - if len(hash) == sha256AsBase64Size { - hash, err = rescueSha256FromBase64(hash) - if err != nil { - return nil, err - } - } - - if len(hash) != sha256.Size { - return nil, fmt.Errorf("cannot import sha256 bytes of length %d, must be %d", len(hash), sha256.Size) - } - - key := fmt.Sprintf("%x", hash) - bytes, err := fetcher.FetchSha256(key) - if err != nil { - return nil, err - } - - return env.Eval(bytes) - }, + env.scraps = make(map[Sha256Hash]*Scrap) + env.evalImport = func(algo string, hash []byte) (Value, error) { + scrap, err := env.fetch(algo, hash) + if err != nil { + return nil, err } + return env.Eval(scrap) + } + env.inferImport = func(algo string, hash []byte) (types.TypeRef, error) { + scrap, err := env.fetch(algo, hash) + if err != nil { + return types.NeverRef, err + } + return env.infer(scrap) } - return env } -const sha256AsBase64Size = 48 +func (e *Environment) UseFetcher(fetcher yards.Fetcher) { + e.fetcher = fetcher +} + +func (e *Environment) fetch(algo string, hash []byte) (*Scrap, error) { + if algo != "sha256" { + return nil, fmt.Errorf("only sha256 imports are supported") + } + + if len(hash) != sha256.Size { + return nil, fmt.Errorf("cannot import sha256 bytes of length %d, must be %d", len(hash), sha256.Size) + } + + if scrap, ok := e.scraps[(Sha256Hash)(hash)]; ok { + return scrap, nil + } -func rescueSha256FromBase64(encoded []byte) ([]byte, error) { - return hex.DecodeString(base64.StdEncoding.EncodeToString(encoded)) + if e.fetcher == nil { + return nil, fmt.Errorf("cannot import without a fetcher") + } + + key := fmt.Sprintf("%x", hash) + bytes, err := e.fetcher.FetchSha256(key) + if err != nil { + return nil, err + } + + return e.Read(bytes) } -func (e *Environment) Eval(script []byte) (Value, error) { +func (e *Environment) Read(script []byte) (*Scrap, error) { src := token.NewSource(script) se, err := parser.Parse(&src) @@ -81,7 +87,36 @@ func (e *Environment) Eval(script []byte) (Value, error) { return nil, fmt.Errorf("parse error: %w", err) } - return Eval(se, &e.reg, e.vars) + scrap := &Scrap{expr: se} + e.scraps[sha256.Sum256(script)] = scrap + return scrap, nil +} + +// Eval evaluates a Scrap. +func (e *Environment) Eval(scrap *Scrap) (Value, error) { + if scrap.value == nil { + value, err := Eval(scrap.expr, &e.reg, e.vars, e.evalImport) + scrap.value = value + return value, err + } + return scrap.value, nil +} + +func (e *Environment) infer(scrap *Scrap) (types.TypeRef, error) { + if scrap.typ == types.NeverRef { + // TODO: Add a complete type scope. + scope := types.DefaultScope(&e.reg) + ref, err := types.Infer(&e.reg, scope, scrap.expr, e.inferImport) + scrap.typ = ref + return ref, err + } + return scrap.typ, nil +} + +// Infer returns the string representation of the type of a Scrap. +func (e *Environment) Infer(scrap *Scrap) (string, error) { + ref, err := e.infer(scrap) + return e.reg.String(ref), err } // Scrap renders a Value as self-contained scrapscript program. diff --git a/eval/eval.go b/eval/eval.go index b2d086c..4eb7294 100644 --- a/eval/eval.go +++ b/eval/eval.go @@ -2,6 +2,7 @@ package eval import ( "encoding/base64" + "encoding/hex" "fmt" "maps" "reflect" @@ -14,11 +15,14 @@ import ( "github.com/Victorystick/scrapscript/types" ) +type EvalImport func(algo string, hash []byte) (Value, error) + type context struct { - source *token.Source - reg *types.Registry - vars Vars - parent *context + source *token.Source + reg *types.Registry + vars Vars + evalImport EvalImport + parent *context } type Vars interface { @@ -64,7 +68,7 @@ func (c *context) name(id *ast.Ident) string { } func (c *context) sub(vars Vars) *context { - return &context{c.source, c.reg, vars, c} + return &context{c.source, c.reg, vars, c.evalImport, c} } func (c *context) error(span token.Span, msg string) error { @@ -72,8 +76,8 @@ func (c *context) error(span token.Span, msg string) error { } // Eval evaluates a SourceExpr in the context of a set of variables. -func Eval(se ast.SourceExpr, reg *types.Registry, vars Vars) (Value, error) { - ctx := &context{&se.Source, reg, vars, nil} +func Eval(se ast.SourceExpr, reg *types.Registry, vars Vars, evalImport EvalImport) (Value, error) { + ctx := &context{&se.Source, reg, vars, evalImport, nil} return ctx.eval(se.Expr) } @@ -102,6 +106,12 @@ func (c *context) eval(x ast.Node) (Value, error) { return c.createMatchFunc(x) case *ast.AccessExpr: return c.access(x) + case *ast.ImportExpr: + bs, err := hex.DecodeString(c.source.GetString(x.Value.Pos.TrimStart(2))) + if err != nil { + return nil, c.error(x.Span(), fmt.Sprintf("bad import hash %#v", x)) + } + return c.evalImport(x.HashAlgo, bs) } return nil, c.error(x.Span(), fmt.Sprintf("unhandled node %#v", x)) diff --git a/eval/eval_test.go b/eval/eval_test.go index 6ccd0ca..16d47b7 100644 --- a/eval/eval_test.go +++ b/eval/eval_test.go @@ -162,11 +162,19 @@ func TestFailures(t *testing.T) { } } +func eval(e *Environment, source string) (Value, error) { + scrap, err := e.Read([]byte(source)) + if err != nil { + return nil, err + } + return e.Eval(scrap) +} + // Evaluates an expression and compares the string representation of the // result with a target string; optionally with some additional variables // in scope. func evalString(t *testing.T, source, expected string) { - val, err := NewEnvironment(nil).Eval([]byte(source)) + val, err := eval(NewEnvironment(), source) if err != nil { t.Error(err) @@ -179,7 +187,7 @@ func evalString(t *testing.T, source, expected string) { // Evaluates to a comparable value func evalFailure(t *testing.T, source string, expected string) { - val, err := NewEnvironment(nil).Eval([]byte(source)) + val, err := eval(NewEnvironment(), source) if err == nil { t.Errorf("%s - should fail but got %s", source, val) @@ -191,12 +199,13 @@ func evalFailure(t *testing.T, source string, expected string) { } func TestEvalImport(t *testing.T) { - env := NewEnvironment(MapFetcher{ + env := NewEnvironment() + env.UseFetcher(MapFetcher{ "a948904f2f0f479b8f8197694b30184b0d2ed1c1cd2a1ec0fb85d299a192a447": `3 + $sha256~~a948904f2f0f479b8f8197694b30184b0d2ed1c1cd2a1ec0fb85d299a192a445`, "a948904f2f0f479b8f8197694b30184b0d2ed1c1cd2a1ec0fb85d299a192a445": `2`, }) - val, err := env.Eval([]byte(`$sha256~~a948904f2f0f479b8f8197694b30184b0d2ed1c1cd2a1ec0fb85d299a192a447 - 1`)) + val, err := eval(env, `$sha256~~a948904f2f0f479b8f8197694b30184b0d2ed1c1cd2a1ec0fb85d299a192a447 - 1`) if err != nil { t.Error(err) } else { diff --git a/parser/parser.go b/parser/parser.go index 6850801..88ed6ed 100644 --- a/parser/parser.go +++ b/parser/parser.go @@ -204,6 +204,9 @@ func (p *parser) parseUnaryExpr() ast.Expr { case token.OPTION: return p.parseEnum() + + case token.IMPORT: + return p.parseImport() } p.unexpected() @@ -472,3 +475,29 @@ func (p *parser) parseVariant() *ast.VariantExpr { Typ: typ, } } + +func (p *parser) parseImport() *ast.ImportExpr { + start := p.span.Start + + // Eat $. + p.next() + + // Hash algo. + p.expect(token.IDENT) + algo := p.source.GetString(p.span) + p.next() + + p.expect(token.BYTES) + bytes := ast.Literal{ + Pos: p.span, + Kind: p.tok, + } + end := p.span.End + p.next() + + return &ast.ImportExpr{ + Pos: token.Span{Start: start, End: end}, + HashAlgo: algo, + Value: bytes, + } +} diff --git a/parser/parser_test.go b/parser/parser_test.go index ea9378e..451ba8f 100644 --- a/parser/parser_test.go +++ b/parser/parser_test.go @@ -129,6 +129,23 @@ func TestParses(t *testing.T) { } } +func TestImports(t *testing.T) { + valid := []string{ + `$sha256~~a948904f2f0f479b8f8197694b30184b0d2ed1c1cd2a1ec0fb85d299a192a447`, + } + + for _, src := range valid { + se, err := ParseExpr(src) + if err != nil { + writeParseError(t, src, err) + } + + if _, ok := se.Expr.(*ast.ImportExpr); !ok { + t.Errorf("Expected an ImportExpr, got %T", se.Expr) + } + } +} + func TestParseError(t *testing.T) { examples := []struct{ source, message string }{ {`{ a = b ..c }`, `Expected RBRACE got SPREAD`}, diff --git a/scanner/scanner.go b/scanner/scanner.go index eced12e..6ebfb4d 100644 --- a/scanner/scanner.go +++ b/scanner/scanner.go @@ -249,6 +249,8 @@ func (s *Scanner) Scan() (token.Token, token.Span) { switch ch { case eof: return token.EOF, token.Span{Start: start, End: start} + case '$': + return s.char(token.IMPORT) case '(': return s.switch2(token.LPAREN, ')', token.HOLE) case ')': @@ -323,7 +325,7 @@ func isAlpha(ch rune) bool { } func isLetter(ch rune) bool { - return 'a' <= lower(ch) && lower(ch) <= 'z' || ch == '$' || ch == '_' || ch >= utf8.RuneSelf && unicode.IsLetter(ch) + return 'a' <= lower(ch) && lower(ch) <= 'z' || ch == '_' || ch >= utf8.RuneSelf && unicode.IsLetter(ch) } func isDigit(ch rune) bool { return isDecimal(ch) || ch >= utf8.RuneSelf && unicode.IsDigit(ch) diff --git a/scanner/scanner_test.go b/scanner/scanner_test.go index 5215fed..2d6568d 100644 --- a/scanner/scanner_test.go +++ b/scanner/scanner_test.go @@ -32,7 +32,8 @@ var elements = []elt{ // Special tokens {token.IDENT, "hello", literal}, {token.IDENT, "f", literal}, - {token.IDENT, "$sha256", literal}, // Import + {token.IMPORT, "$", operator}, // Import + {token.IDENT, "sha256", literal}, // Hash algo {token.IDENT, "bytes/to-utf8-text", literal}, {token.INT, "13", literal}, {token.INT, "-13", literal}, diff --git a/token/token.go b/token/token.go index 01030e8..9efd4d3 100644 --- a/token/token.go +++ b/token/token.go @@ -20,6 +20,9 @@ const ( begin_operators HOLE // () + // Import + IMPORT + // Where clauses. ASSIGN // = @@ -90,6 +93,8 @@ var tokens = [...]string{ WHERE: "WHERE", COMMA: "COMMA", + IMPORT: "IMPORT", + DEFINE: "DEFINE", PICK: "PICK", OPTION: "OPTION", @@ -130,6 +135,8 @@ var operators = [...]string{ WHERE: ";", COMMA: ",", + IMPORT: "$", + DEFINE: ":", PICK: "::", OPTION: "#", diff --git a/types/infer.go b/types/infer.go index 98fd8e8..cd040c1 100644 --- a/types/infer.go +++ b/types/infer.go @@ -1,6 +1,7 @@ package types import ( + "encoding/hex" "fmt" "github.com/Victorystick/scrapscript/ast" @@ -36,10 +37,13 @@ func (s *Scope[T]) Bind(name string, val T) *Scope[T] { type TypeScope = *Scope[TypeRef] +type InferImport func(algo string, hash []byte) (TypeRef, error) + type context struct { - source token.Source - reg *Registry - scope TypeScope + source token.Source + reg *Registry + scope TypeScope + inferImport InferImport } func (c *context) bail(span token.Span, msg string) { @@ -56,28 +60,19 @@ func (c *context) unbind() { c.scope = c.scope.parent } -func DefaultScope() (reg Registry, scope TypeScope) { +func DefaultScope(reg *Registry) (scope TypeScope) { for _, p := range primitives { scope = scope.Bind(reg.String(p), p) } return } -func Infer(se ast.SourceExpr) (string, error) { - reg, scope := DefaultScope() - - ref, err := InferInScope(®, scope, se) - if err != nil { - return "", err - } - return reg.String(ref), nil -} - -func InferInScope(reg *Registry, scope TypeScope, se ast.SourceExpr) (ref TypeRef, err error) { +func Infer(reg *Registry, scope TypeScope, se ast.SourceExpr, inferImport InferImport) (ref TypeRef, err error) { context := context{ - source: se.Source, - reg: reg, - scope: scope, + source: se.Source, + reg: reg, + scope: scope, + inferImport: inferImport, } defer func() { @@ -170,6 +165,19 @@ func (c *context) infer(expr ast.Expr) TypeRef { return c.ensure(x.Right, right, IntRef) } panic(fmt.Sprintf("can't infer binary expression %s", x.Op.String())) + case *ast.ImportExpr: + if c.inferImport == nil { + c.bail(x.Span(), " missing infer import function") + } + bs, err := hex.DecodeString(c.source.GetString(x.Value.Pos.TrimStart(2))) + if err != nil { + c.bail(x.Span(), fmt.Sprintf("bad import hash %#v", x)) + } + ref, err := c.inferImport(x.HashAlgo, bs) + if err != nil { + c.bail(x.Span(), err.Error()) + } + return ref } panic(fmt.Sprintf("can't infer node %T", expr)) diff --git a/types/infer_test.go b/types/infer_test.go index 6cffa32..de0d8fe 100644 --- a/types/infer_test.go +++ b/types/infer_test.go @@ -1,6 +1,7 @@ package types import ( + "fmt" "strings" "testing" @@ -79,12 +80,14 @@ func TestInfer(t *testing.T) { for _, ex := range examples { se := must(parser.ParseExpr(ex.source)) - typ, err := Infer(se) + var reg Registry + typ, err := Infer(®, DefaultScope(®), se, nil) if err != nil { t.Error(err) } else { - if typ != ex.typ { - t.Errorf("Expected %s, got %s", ex.typ, typ) + typStr := reg.String(typ) + if typStr != ex.typ { + t.Errorf("Expected %s, got %s", ex.typ, typStr) } } } @@ -111,11 +114,14 @@ func TestInferFailure(t *testing.T) { {`f ; f : int -> text = a -> 1`, `cannot unify 'int' with 'text'`}, // Math {`1 + 1.0`, `cannot unify 'int' with 'float'`}, + // No imports. + {`$sha256~~`, ` missing infer import function`}, } for _, ex := range examples { + var reg Registry se := must(parser.ParseExpr(ex.source)) - _, err := Infer(se) + _, err := Infer(®, DefaultScope(®), se, nil) if err != nil { str := err.Error() if !strings.Contains(str, ex.message) { @@ -150,7 +156,7 @@ func TestInferInScope(t *testing.T) { a := reg.Unbound() scope = scope.Bind("id", reg.Func(a, a)) - ref, err := InferInScope(®, scope, se) + ref, err := Infer(®, scope, se, nil) if err != nil { t.Error(err) } else { @@ -161,3 +167,60 @@ func TestInferInScope(t *testing.T) { } } } + +type MapFetcher map[string]string + +func (mf MapFetcher) FetchSha256(key string) ([]byte, error) { + source, ok := mf[key] + if !ok { + return nil, fmt.Errorf("can't import '%s'", key) + } + return []byte(source), nil +} + +func TestInferImport(t *testing.T) { + var reg Registry + + a := reg.Var() + + examples := []struct { + in string // The input. + imp TypeRef // The imported type. + result string // The stringified result type, or + err string // an error. + }{ + // Since the `InferImport` function below doesn't check the hash length + // '$sha256~~' is sufficient to encode an import. + {in: `$sha256~~`, imp: IntRef, result: `int`}, + {in: `$sha256~~`, imp: FloatRef, result: `float`}, + {in: `1 + $sha256~~`, imp: FloatRef, err: `cannot unify 'int' with 'float'`}, + {in: `$sha256~~`, imp: a, result: `$0`}, + {in: `a ; a = $sha256~~`, imp: a, result: `$0`}, + {in: `$sha256~~ [ 1, 2 ]`, imp: reg.Func(a, a), result: `list int`}, + // TODO: Aliasing allocates new type vars, just importing does not. :/ + {in: `a ; a = $sha256~~`, imp: reg.Func(a, a), result: `$2 -> $2`}, + {in: `a ; a = $sha256~~`, imp: reg.Func(a, a), result: `$3 -> $3`}, + } + + for _, ex := range examples { + se := must(parser.ParseExpr(ex.in)) + typ, err := Infer(®, DefaultScope(®), se, func(algo string, hash []byte) (TypeRef, error) { + return ex.imp, nil + }) + if err != nil { + if ex.err != "" { + str := err.Error() + if !strings.Contains(str, ex.err) { + t.Errorf("Expected '%s' to be in error:\n%s", ex.err, str) + } + } else { + t.Error(err) + } + } else { + typStr := reg.String(typ) + if typStr != ex.result { + t.Errorf("Expected %s, got %s", ex.result, typStr) + } + } + } +} diff --git a/yards/cache.go b/yards/cache.go index ac07f95..5d70756 100644 --- a/yards/cache.go +++ b/yards/cache.go @@ -26,12 +26,19 @@ func (c *cachingFetcher) FetchSha256(key string) ([]byte, error) { return bs, os.WriteFile(filepath.Join(c.path, key), bs, 0644) } -func NewCacheFetcher(pathname string, fetcher Fetcher) Fetcher { +func NewCacheFetcher(pathname string, fetcher Fetcher) (Fetcher, error) { + // Create the cache directory if it doesn't exist. + if _, err := os.Stat(pathname); os.IsNotExist(err) { + err = os.MkdirAll(pathname, 0700) + if err != nil { + return nil, err + } + } return &cachingFetcher{ path: pathname, main: ByDirectory(os.DirFS(pathname)), fallback: fetcher, - } + }, nil } func NewDefaultCacheFetcher(fetcher Fetcher) (Fetcher, error) { @@ -40,5 +47,5 @@ func NewDefaultCacheFetcher(fetcher Fetcher) (Fetcher, error) { return nil, err } - return NewCacheFetcher(filepath.Join(dir, "scrapscript"), fetcher), nil + return NewCacheFetcher(filepath.Join(dir, "scrapscript/sha256"), fetcher) } diff --git a/yards/cache_test.go b/yards/cache_test.go index 388ff10..8cefb85 100644 --- a/yards/cache_test.go +++ b/yards/cache_test.go @@ -17,9 +17,12 @@ func TestCache(t *testing.T) { t.Error("expected not to read key1") } - f := NewCacheFetcher(root, ByDirectory(fstest.MapFS{ + f, err := NewCacheFetcher(root, ByDirectory(fstest.MapFS{ "key1": {Data: []byte("first")}, })) + if err != nil { + t.Error("could not create cache directory") + } bs, err := f.FetchSha256("key1") if err != nil {