Answer architecture round 3: New loads only sources without an index and what they read, an entry drops reads, ErrLoad sits beside New, the manifest decoder moves to manifest.go and names each mistake once as jsonvalue.Kind words it
This commit is contained in:
@@ -2,13 +2,10 @@
|
||||
package datafiles
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/larvit/fejkdata/internal/grammar"
|
||||
@@ -59,180 +56,6 @@ func (s Source) dirPath(dir []string) string {
|
||||
return "."
|
||||
}
|
||||
|
||||
// ManifestFile is the file at a source's root that describes the source.
|
||||
const ManifestFile = ".fejkdata.json"
|
||||
|
||||
// Manifest is what a source's manifest says of it.
|
||||
type Manifest struct {
|
||||
// Index is every category of the source by dot path; nil where the manifest has none.
|
||||
Index map[string]IndexEntry `json:"index"`
|
||||
}
|
||||
|
||||
// IndexEntry is what an index says of a category: its parent table, "" for none, the paths
|
||||
// List advertises below it, and the categories its templates reference.
|
||||
type IndexEntry struct {
|
||||
Parent string `json:"parent,omitempty"`
|
||||
Paths []string `json:"paths"`
|
||||
Reads []string `json:"reads,omitempty"`
|
||||
}
|
||||
|
||||
// Manifest reads the manifest at the source's root, the zero Manifest where there is none.
|
||||
func (s Source) Manifest() (Manifest, error) {
|
||||
if err := s.check(); err != nil {
|
||||
return Manifest{}, err
|
||||
}
|
||||
b, err := fs.ReadFile(s.fsys, path.Join(s.base, ManifestFile))
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return Manifest{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return Manifest{}, fmt.Errorf("%s: %w", s.ManifestPath(), err)
|
||||
}
|
||||
m, err := decodeManifest(b)
|
||||
if err != nil {
|
||||
return Manifest{}, fmt.Errorf("%s: %w", s.ManifestPath(), err)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// ManifestPath is the manifest's path as errors name it.
|
||||
func (s Source) ManifestPath() string { return s.labelled(path.Join(s.base, ManifestFile)) }
|
||||
|
||||
// decodeManifest decodes a manifest, refusing any key or shape it does not define, and
|
||||
// reports every mistake in the order of the entries naming them.
|
||||
func decodeManifest(b []byte) (Manifest, error) {
|
||||
var raw any
|
||||
if err := json.Unmarshal(b, &raw); err != nil {
|
||||
return Manifest{}, err
|
||||
}
|
||||
top, isObject := raw.(map[string]any)
|
||||
if !isObject {
|
||||
return Manifest{}, fmt.Errorf("a manifest is a JSON object")
|
||||
}
|
||||
var m Manifest
|
||||
var errs []error
|
||||
for _, k := range sortedKeys(top) {
|
||||
if k != "index" {
|
||||
errs = append(errs, fmt.Errorf("unknown key %q; a manifest holds index", k))
|
||||
continue
|
||||
}
|
||||
index, isObject := top[k].(map[string]any)
|
||||
if !isObject {
|
||||
errs = append(errs, fmt.Errorf("index is a JSON object of category paths"))
|
||||
continue
|
||||
}
|
||||
m.Index = map[string]IndexEntry{}
|
||||
for _, p := range sortedKeys(index) {
|
||||
e, err := decodeEntry(p, index[p])
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("index entry %q: %w", p, err))
|
||||
}
|
||||
m.Index[p] = e
|
||||
}
|
||||
}
|
||||
for _, p := range sortedKeys(m.Index) {
|
||||
if err := checkParent(m.Index, p); err != nil {
|
||||
errs = append(errs, fmt.Errorf("index entry %q: %w", p, err))
|
||||
}
|
||||
}
|
||||
if len(errs) > 0 {
|
||||
return Manifest{}, errors.Join(errs...)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func decodeEntry(p string, v any) (IndexEntry, error) {
|
||||
var e IndexEntry
|
||||
errs := []error{checkCategoryPath(p)}
|
||||
fields, isObject := v.(map[string]any)
|
||||
if !isObject {
|
||||
return e, fmt.Errorf("an entry is a JSON object of parent, paths and reads")
|
||||
}
|
||||
if _, has := fields["paths"]; !has {
|
||||
errs = append(errs, fmt.Errorf("paths is missing"))
|
||||
}
|
||||
var err error
|
||||
for _, k := range sortedKeys(fields) {
|
||||
switch k {
|
||||
case "parent":
|
||||
var isString bool
|
||||
if e.Parent, isString = fields[k].(string); !isString {
|
||||
err = fmt.Errorf("parent is a string")
|
||||
} else if err = grammar.CheckIdentifier(e.Parent); err != nil {
|
||||
err = fmt.Errorf("parent %w", err)
|
||||
}
|
||||
case "paths":
|
||||
e.Paths, err = stringList(k, fields[k], func(q string) error {
|
||||
if q == "" {
|
||||
return nil
|
||||
}
|
||||
return checkCategoryPath(q)
|
||||
})
|
||||
case "reads":
|
||||
e.Reads, err = stringList(k, fields[k], checkCategoryPath)
|
||||
default:
|
||||
err = fmt.Errorf("unknown key %q; an entry holds parent, paths and reads", k)
|
||||
}
|
||||
errs = append(errs, err)
|
||||
}
|
||||
return e, errors.Join(errs...)
|
||||
}
|
||||
|
||||
// stringList is the JSON array of strings v, each passing check.
|
||||
func stringList(key string, v any, check func(string) error) ([]string, error) {
|
||||
items, isArray := v.([]any)
|
||||
if !isArray {
|
||||
return nil, fmt.Errorf("%s is an array of strings", key)
|
||||
}
|
||||
out := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
s, isString := item.(string)
|
||||
if !isString {
|
||||
return nil, fmt.Errorf("%s is an array of strings", key)
|
||||
}
|
||||
if err := check(s); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", key, err)
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// checkParent refuses a parent naming no entry beside p: a table loads with its parent,
|
||||
// from one source.
|
||||
func checkParent(index map[string]IndexEntry, p string) error {
|
||||
parent := index[p].Parent
|
||||
if parent == "" {
|
||||
return nil
|
||||
}
|
||||
sibling := parent
|
||||
if dot := strings.LastIndexByte(p, '.'); dot >= 0 {
|
||||
sibling = p[:dot+1] + parent
|
||||
}
|
||||
if _, ok := index[sibling]; !ok {
|
||||
return fmt.Errorf("parent %q names no entry beside it in this index", parent)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkCategoryPath(p string) error {
|
||||
for _, seg := range strings.Split(p, ".") {
|
||||
if err := grammar.CheckIdentifier(seg); err != nil {
|
||||
return fmt.Errorf("%q: %w", p, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sortedKeys[V any](m map[string]V) []string {
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
// Walk hands compile every category of the tree, folders and files in name order. A
|
||||
// folder holding no category anywhere below it is skipped; a hidden file or folder is no
|
||||
// category, though a category may name a hidden rows file beside it.
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
package datafiles
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"path"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/larvit/fejkdata/internal/grammar"
|
||||
"github.com/larvit/fejkdata/internal/jsonvalue"
|
||||
)
|
||||
|
||||
// ManifestFile is the file at a source's root that describes the source.
|
||||
const ManifestFile = ".fejkdata.json"
|
||||
|
||||
// Manifest is what a source's manifest says of it.
|
||||
type Manifest struct {
|
||||
// Index is every category of the source by dot path; nil where the manifest has none.
|
||||
Index map[string]IndexEntry `json:"index"`
|
||||
}
|
||||
|
||||
// IndexEntry is what an index says of a category: its parent table, "" for none, and the
|
||||
// paths List advertises below it.
|
||||
type IndexEntry struct {
|
||||
Parent string `json:"parent,omitempty"`
|
||||
Paths []string `json:"paths"`
|
||||
}
|
||||
|
||||
// Manifest reads the manifest at the source's root, the zero Manifest where there is none.
|
||||
func (s Source) Manifest() (Manifest, error) {
|
||||
if err := s.check(); err != nil {
|
||||
return Manifest{}, err
|
||||
}
|
||||
b, err := fs.ReadFile(s.fsys, path.Join(s.base, ManifestFile))
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return Manifest{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return Manifest{}, fmt.Errorf("%s: %w", s.ManifestPath(), err)
|
||||
}
|
||||
m, err := decodeManifest(b)
|
||||
if err != nil {
|
||||
return Manifest{}, fmt.Errorf("%s: %w", s.ManifestPath(), err)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// ManifestPath is the manifest's path as errors name it.
|
||||
func (s Source) ManifestPath() string { return s.labelled(path.Join(s.base, ManifestFile)) }
|
||||
|
||||
// decodeManifest decodes a manifest, refusing any key or shape it does not define, and
|
||||
// reports every mistake: first each entry's own, in path order, then each parent naming
|
||||
// no entry.
|
||||
func decodeManifest(b []byte) (Manifest, error) {
|
||||
raw, err := jsonvalue.Decode(b)
|
||||
if err != nil {
|
||||
return Manifest{}, err
|
||||
}
|
||||
top, isObject := raw.Any().(map[string]any)
|
||||
if !isObject {
|
||||
return Manifest{}, fmt.Errorf("a manifest must be an object, not %s", jsonvalue.Kind(raw.Any()))
|
||||
}
|
||||
var m Manifest
|
||||
var errs []error
|
||||
for _, k := range sortedKeys(top) {
|
||||
if k != "index" {
|
||||
errs = append(errs, fmt.Errorf("unknown key %q; a manifest holds index", k))
|
||||
continue
|
||||
}
|
||||
var indexErrs []error
|
||||
m.Index, indexErrs = decodeIndex(top[k])
|
||||
errs = append(errs, indexErrs...)
|
||||
}
|
||||
return m, errors.Join(errs...)
|
||||
}
|
||||
|
||||
func decodeIndex(v any) (map[string]IndexEntry, []error) {
|
||||
entries, isObject := v.(map[string]any)
|
||||
if !isObject {
|
||||
return nil, []error{fmt.Errorf("index must be an object, not %s", jsonvalue.Kind(v))}
|
||||
}
|
||||
index := map[string]IndexEntry{}
|
||||
var errs []error
|
||||
for _, p := range sortedKeys(entries) {
|
||||
e, err := decodeEntry(p, entries[p])
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("index entry %q: %w", p, err))
|
||||
}
|
||||
index[p] = e
|
||||
}
|
||||
for _, p := range sortedKeys(index) {
|
||||
if err := checkParent(index, p); err != nil {
|
||||
errs = append(errs, fmt.Errorf("index entry %q: %w", p, err))
|
||||
}
|
||||
}
|
||||
return index, errs
|
||||
}
|
||||
|
||||
// decodeEntry decodes one entry, leaving out a parent it refuses, so checkParent names no
|
||||
// mistake twice.
|
||||
func decodeEntry(p string, v any) (IndexEntry, error) {
|
||||
var e IndexEntry
|
||||
errs := []error{checkCategoryPath(p)}
|
||||
fields, isObject := v.(map[string]any)
|
||||
if !isObject {
|
||||
errs = append(errs, fmt.Errorf("an entry must be an object, not %s", jsonvalue.Kind(v)))
|
||||
return e, errors.Join(errs...)
|
||||
}
|
||||
if _, has := fields["paths"]; !has {
|
||||
errs = append(errs, fmt.Errorf("paths is missing"))
|
||||
}
|
||||
for _, k := range sortedKeys(fields) {
|
||||
switch k {
|
||||
case "parent":
|
||||
parent, err := decodeParent(fields[k])
|
||||
e.Parent = parent
|
||||
errs = append(errs, err)
|
||||
case "paths":
|
||||
var err error
|
||||
e.Paths, err = decodePaths(fields[k])
|
||||
errs = append(errs, err)
|
||||
default:
|
||||
errs = append(errs, fmt.Errorf("unknown key %q; an entry holds parent and paths", k))
|
||||
}
|
||||
}
|
||||
return e, errors.Join(errs...)
|
||||
}
|
||||
|
||||
func decodeParent(v any) (string, error) {
|
||||
parent, isString := v.(string)
|
||||
if !isString {
|
||||
return "", fmt.Errorf("parent must be a string, not %s", jsonvalue.Kind(v))
|
||||
}
|
||||
if err := grammar.CheckIdentifier(parent); err != nil {
|
||||
return "", fmt.Errorf("parent %w", err)
|
||||
}
|
||||
return parent, nil
|
||||
}
|
||||
|
||||
// decodePaths decodes a list of paths below a category, "" being the category itself.
|
||||
func decodePaths(v any) ([]string, error) {
|
||||
items, isList := v.([]any)
|
||||
if !isList {
|
||||
return nil, fmt.Errorf("paths must be a list, not %s", jsonvalue.Kind(v))
|
||||
}
|
||||
out := make([]string, 0, len(items))
|
||||
var errs []error
|
||||
for i, item := range items {
|
||||
q, isString := item.(string)
|
||||
if !isString {
|
||||
errs = append(errs, fmt.Errorf("paths item %d must be a string, not %s", i+1, jsonvalue.Kind(item)))
|
||||
continue
|
||||
}
|
||||
if q != "" {
|
||||
if err := checkCategoryPath(q); err != nil {
|
||||
errs = append(errs, fmt.Errorf("paths item %d: %w", i+1, err))
|
||||
continue
|
||||
}
|
||||
}
|
||||
out = append(out, q)
|
||||
}
|
||||
return out, errors.Join(errs...)
|
||||
}
|
||||
|
||||
// checkParent refuses a parent naming no entry beside p: a table loads with its parent,
|
||||
// from one source.
|
||||
func checkParent(index map[string]IndexEntry, p string) error {
|
||||
parent := index[p].Parent
|
||||
if parent == "" {
|
||||
return nil
|
||||
}
|
||||
sibling := parent
|
||||
if dot := strings.LastIndexByte(p, '.'); dot >= 0 {
|
||||
sibling = p[:dot+1] + parent
|
||||
}
|
||||
if _, ok := index[sibling]; !ok {
|
||||
return fmt.Errorf("parent %q names no entry beside it in this index", parent)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkCategoryPath(p string) error {
|
||||
for _, seg := range strings.Split(p, ".") {
|
||||
if err := grammar.CheckIdentifier(seg); err != nil {
|
||||
return fmt.Errorf("%q: %w", p, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sortedKeys[V any](m map[string]V) []string {
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
@@ -4,6 +4,7 @@ package jsonvalue
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Value is a JSON value that only Decode, String and Object make, so a template never compiles
|
||||
@@ -37,3 +38,22 @@ func Object(fields map[string]Value) Value {
|
||||
// Any is the value as encoding/json decodes it: a map[string]any, []any, string, json.Number,
|
||||
// bool or nil.
|
||||
func (v Value) Any() any { return v.v }
|
||||
|
||||
// Kind names the kind of a value as Any gives it, such as "a number" or "a list".
|
||||
func Kind(v any) string {
|
||||
switch v.(type) {
|
||||
case json.Number:
|
||||
return "a number"
|
||||
case bool:
|
||||
return "a boolean"
|
||||
case string:
|
||||
return "a string"
|
||||
case []any:
|
||||
return "a list"
|
||||
case map[string]any:
|
||||
return "an object"
|
||||
case nil:
|
||||
return "null"
|
||||
}
|
||||
return fmt.Sprintf("%T", v)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user