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:
2026-10-10 11:07:16 +02:00
committed by Lillem4n
parent 2cb124b8a7
commit 55d503317d
16 changed files with 294 additions and 352 deletions
-177
View File
@@ -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.
+200
View File
@@ -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
}
+20
View File
@@ -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)
}