~ knotless Search/
↑↓ move↵ open⇧↵ all results
0
0
main
209 lines · 5.7 KB Raw History
package apigen

import (
	"encoding/json"
	"errors"
	"fmt"
	"go/ast"
	"go/parser"
	"go/token"
	"os"
	"path/filepath"
	"slices"
	"sort"
	"strings"
)

const lexiconMarker = "+socialweb:lexicon="

type lexiconDef struct {
	Type        string                 `json:"type"`
	Ref         string                 `json:"ref"`
	Properties  map[string]*lexiconDef `json:"properties"`
	Items       *lexiconDef            `json:"items"`
	KnownValues []string               `json:"knownValues"`
	Enum        []string               `json:"enum"`
	Minimum     *int64                 `json:"minimum"`
	Maximum     *int64                 `json:"maximum"`
}

type lexiconDoc struct {
	ID   string                 `json:"id"`
	Defs map[string]*lexiconDef `json:"defs"`
}

type lexiconSet struct {
	root string
	docs map[string]*lexiconDoc
}

func readLexiconMarkers(dir string) (map[string]string, error) {
	entries, err := os.ReadDir(dir)
	if err != nil {
		return nil, err
	}
	fset := token.NewFileSet()
	out := map[string]string{}
	for _, e := range entries {
		name := e.Name()
		if e.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") {
			continue
		}
		f, err := parser.ParseFile(fset, filepath.Join(dir, name), nil, parser.ParseComments)
		if err != nil {
			return nil, err
		}
		for _, decl := range f.Decls {
			gen, ok := decl.(*ast.GenDecl)
			if !ok || gen.Tok != token.TYPE || gen.Doc == nil || len(gen.Specs) != 1 {
				continue
			}
			for _, c := range gen.Doc.List {
				if ref, ok := strings.CutPrefix(strings.TrimSpace(strings.TrimPrefix(c.Text, "//")), lexiconMarker); ok {
					out[gen.Specs[0].(*ast.TypeSpec).Name.Name] = strings.TrimSpace(ref)
				}
			}
		}
	}
	return out, nil
}

func (l *lexiconSet) def(base, ref string) (*lexiconDef, string, error) {
	nsid, name, ok := strings.Cut(ref, "#")
	if !ok {
		name = "main"
	}
	if nsid == "" {
		nsid = base
	}
	doc, ok := l.docs[nsid]
	if !ok {
		data, err := os.ReadFile(filepath.Join(l.root, "lexicons", filepath.FromSlash(strings.ReplaceAll(nsid, ".", "/"))+".json"))
		if err != nil {
			return nil, "", fmt.Errorf("lexicon %s: %w", nsid, err)
		}
		doc = &lexiconDoc{}
		if err := json.Unmarshal(data, doc); err != nil {
			return nil, "", fmt.Errorf("lexicon %s: %w", nsid, err)
		}
		l.docs[nsid] = doc
	}
	d, ok := doc.Defs[name]
	if !ok {
		return nil, "", fmt.Errorf("lexicon %s has no def %s", nsid, name)
	}
	return d, nsid, nil
}

func (l *lexiconSet) compare(s *Schema, d *lexiconDef, base, path string) []error {
	if d.Type == "ref" {
		next, nsid, err := l.def(base, d.Ref)
		if err != nil {
			return []error{fmt.Errorf("%s: %w", path, err)}
		}
		return l.compare(s, next, nsid, path)
	}
	mismatch := func(format string, args ...any) []error {
		return []error{fmt.Errorf("%s: "+format, append([]any{path}, args...)...)}
	}
	if s.Type != d.Type {
		return mismatch("the Go type is %s, the lexicon says %s", s.Type, d.Type)
	}
	switch d.Type {
	case TypeObject:
		var errs []error
		goProps := map[string]*Schema{}
		for _, p := range s.Properties {
			goProps[p.Name] = p.Schema
		}
		names := make([]string, 0, len(goProps)+len(d.Properties))
		for n := range goProps {
			names = append(names, n)
		}
		for n := range d.Properties {
			if _, ok := goProps[n]; !ok {
				names = append(names, n)
			}
		}
		sort.Strings(names)
		for _, n := range names {
			gs, lp := goProps[n], d.Properties[n]
			switch {
			case gs == nil:
				errs = append(errs, fmt.Errorf("%s.%s: the lexicon has the field, the Go type does not", path, n))
			case lp == nil:
				errs = append(errs, fmt.Errorf("%s.%s: the Go type has the field, the lexicon does not", path, n))
			default:
				errs = append(errs, l.compare(gs, lp, base, path+"."+n)...)
			}
		}
		return errs
	case TypeArray:
		if s.Items == nil || d.Items == nil {
			return mismatch("an array without items")
		}
		return l.compare(s.Items, d.Items, base, path+"[]")
	case TypeString:
		values := append(slices.Clone(d.KnownValues), d.Enum...)
		if len(s.Enum) == 0 && len(values) == 0 {
			return nil
		}
		if !sameFolded(s.Enum, values) {
			return mismatch("the Go enum is %v, the lexicon values are %v", s.Enum, values)
		}
	case TypeInteger:
		if s.Minimum != nil && d.Minimum != nil && *s.Minimum != *d.Minimum {
			return mismatch("the Go minimum is %d, the lexicon minimum is %d", *s.Minimum, *d.Minimum)
		}
		if s.Maximum != nil && d.Maximum != nil && *s.Maximum != *d.Maximum {
			return mismatch("the Go maximum is %d, the lexicon maximum is %d", *s.Maximum, *d.Maximum)
		}
	case TypeBoolean:
	default:
		return mismatch("the lexicon type %s has no Go mapping", d.Type)
	}
	return nil
}

func sameFolded(a, b []string) bool {
	fold := func(in []string) []string {
		out := make([]string, 0, len(in))
		for _, v := range in {
			out = append(out, strings.ToLower(v))
		}
		sort.Strings(out)
		return slices.Compact(out)
	}
	return slices.Equal(fold(a), fold(b))
}

func checkLexicons(root, dir string, named map[string]*Schema) error {
	markers, err := readLexiconMarkers(filepath.Join(root, filepath.FromSlash(dir)))
	if err != nil {
		return err
	}
	set := &lexiconSet{root: root, docs: map[string]*lexiconDoc{}}
	types := make([]string, 0, len(markers))
	for t := range markers {
		types = append(types, t)
	}
	sort.Strings(types)
	var errs []error
	for _, t := range types {
		ref := markers[t]
		s, ok := named[t]
		if !ok {
			errs = append(errs, fmt.Errorf("%s names the lexicon %s but no resource reaches the type", t, ref))
			continue
		}
		d, nsid, err := set.def("", ref)
		if err != nil {
			errs = append(errs, fmt.Errorf("%s: %w", t, err))
			continue
		}
		for _, e := range set.compare(s, d, nsid, t) {
			errs = append(errs, fmt.Errorf("%s and the lexicon %s diverge: %w", t, ref, e))
		}
	}
	return errors.Join(errs...)
}