321 lines
6.6 KiB
Go
321 lines
6.6 KiB
Go
// Package bkl implements a layered configuration language parser.
|
|
//
|
|
// - Language & tool documentation: https://bkl.gopatchy.io/
|
|
// - Go library source: https://github.com/gopatchy/bkl
|
|
// - Go library documentation: https://pkg.go.dev/github.com/gopatchy/bkl
|
|
package bkl
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"os"
|
|
"slices"
|
|
)
|
|
|
|
// A Parser reads input documents, merges layers, and generates outputs.
|
|
//
|
|
// Terminology:
|
|
// - Each Parser can read multiple files
|
|
// - Each file represents a single layer
|
|
// - Each file contains one or more documents
|
|
// - Each document generates one or more outputs
|
|
type Parser struct {
|
|
docs []any
|
|
debug bool
|
|
}
|
|
|
|
// New creates and returns a new [Parser] with an empty starting document set.
|
|
//
|
|
// New always succeeds and returns a Parser instance.
|
|
func New() *Parser {
|
|
return &Parser{}
|
|
}
|
|
|
|
// NewFromFile creates a new [Parser] then calls [Parser.MergeFileLayers] with
|
|
// the supplied path to merge in the file and its parent layers.
|
|
func NewFromFile(path string) (*Parser, error) {
|
|
p := New()
|
|
|
|
err := p.MergeFileLayers(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return p, nil
|
|
}
|
|
|
|
// SetDebug enables or disables debug log output to stderr.
|
|
func (p *Parser) SetDebug(debug bool) {
|
|
p.debug = debug
|
|
}
|
|
|
|
// MergeParser applies other's internal document state to ours using bkl's
|
|
// merge semantics.
|
|
func (p *Parser) MergeParser(other *Parser) error {
|
|
for i, doc := range other.docs {
|
|
err := p.MergePatch(i, doc)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// MergePatch applies the supplied patch to the [Parser]'s current internal
|
|
// document state at the specified document index using bkl's merge
|
|
// semantics.
|
|
//
|
|
// index is only a hint; if the patch contains a $match entry, that is used
|
|
// instead.
|
|
func (p *Parser) MergePatch(index int, patch any) error {
|
|
if patchMap, ok := canonicalizeType(patch).(map[string]any); ok {
|
|
m, found := patchMap["$match"]
|
|
if found {
|
|
delete(patchMap, "$match")
|
|
|
|
index = -1
|
|
|
|
for i, doc := range p.docs {
|
|
if match(doc, m) {
|
|
index = i
|
|
break
|
|
}
|
|
}
|
|
|
|
if index == -1 {
|
|
return fmt.Errorf("%#v: %w", m, ErrNoMatchFound)
|
|
}
|
|
}
|
|
}
|
|
|
|
if index >= len(p.docs) {
|
|
p.docs = append(p.docs, make([]any, index-len(p.docs)+1)...)
|
|
}
|
|
|
|
merged, err := merge(p.docs[index], patch)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
p.docs[index] = merged
|
|
|
|
return nil
|
|
}
|
|
|
|
// MergeFile parses the file at path and merges its contents into the
|
|
// [Parser]'s document state using bkl's merge semantics.
|
|
func (p *Parser) MergeFile(path string) error {
|
|
p.log("loading %s", path)
|
|
|
|
format, found := formatByExtension[Ext(path)]
|
|
if !found {
|
|
return fmt.Errorf("%s: %w", Ext(path), ErrUnknownFormat)
|
|
}
|
|
|
|
fh, err := os.Open(path)
|
|
if err != nil {
|
|
return fmt.Errorf("%s: %w", path, err)
|
|
}
|
|
|
|
defer fh.Close()
|
|
|
|
b, err := io.ReadAll(fh)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
docs := bytes.SplitAfter(b, []byte("\n---\n"))
|
|
|
|
for i, doc := range docs {
|
|
// Leave the initial \n attached
|
|
doc = bytes.TrimSuffix(doc, []byte("---\n"))
|
|
|
|
patch, err := format.decode(doc)
|
|
if err != nil {
|
|
return fmt.Errorf("%w / %w", err, ErrDecode)
|
|
}
|
|
|
|
err = p.MergePatch(i, patch)
|
|
if err != nil {
|
|
return fmt.Errorf("index %d (of [0,%d]): %w", i, len(docs)-1, err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// MergeFileLayers determines relevant layers from the supplied path and merges
|
|
// them in order.
|
|
func (p *Parser) MergeFileLayers(path string) error {
|
|
paths := []string{
|
|
path,
|
|
}
|
|
|
|
for {
|
|
parent, err := getParent(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if *parent == baseTemplate {
|
|
break
|
|
}
|
|
|
|
path = *parent
|
|
paths = append(paths, path)
|
|
}
|
|
|
|
slices.Reverse(paths)
|
|
|
|
for _, path := range paths {
|
|
err := p.MergeFile(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// NumDocuments returns the number of documents in the [Parser]'s internal
|
|
// state.
|
|
func (p *Parser) NumDocuments() int {
|
|
return len(p.docs)
|
|
}
|
|
|
|
// Document returns the parsed, merged tree for the document at index.
|
|
func (p *Parser) Document(index int) (any, error) {
|
|
if index >= p.NumDocuments() {
|
|
return nil, fmt.Errorf("%d: %w", index, ErrInvalidIndex)
|
|
}
|
|
|
|
return p.docs[index], nil
|
|
}
|
|
|
|
// OutputIndex returns the outputs generated by the document at the
|
|
// specified index, encoded in the specified format.
|
|
func (p *Parser) OutputIndex(index int, ext string) ([][]byte, error) {
|
|
obj, err := p.Document(index)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
obj, err = postMerge(obj)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
err = validate(obj)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
outs := findOutputs(obj)
|
|
if len(outs) == 0 {
|
|
outs = append(outs, obj)
|
|
}
|
|
|
|
f, found := formatByExtension[ext]
|
|
if !found {
|
|
return nil, fmt.Errorf("%s: %w", ext, ErrUnknownFormat)
|
|
}
|
|
|
|
encs := [][]byte{}
|
|
|
|
for _, out := range outs {
|
|
enc, err := f.encode(out)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("index %d (of [0,%d]): %w (%w)", index, p.NumDocuments()-1, err, ErrEncode)
|
|
}
|
|
|
|
encs = append(encs, enc)
|
|
}
|
|
|
|
return encs, nil
|
|
}
|
|
|
|
// Outputs returns all outputs from all documents encoded in the specified
|
|
// format.
|
|
func (p *Parser) Outputs(ext string) ([][]byte, error) {
|
|
outs := [][]byte{}
|
|
|
|
for i := 0; i < p.NumDocuments(); i++ {
|
|
out, err := p.OutputIndex(i, ext)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
outs = append(outs, out...)
|
|
}
|
|
|
|
return outs, nil
|
|
}
|
|
|
|
// Output returns all documents encoded in the specified format and merged into
|
|
// a stream with ---.
|
|
func (p *Parser) Output(ext string) ([]byte, error) {
|
|
outs, err := p.Outputs(ext)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return bytes.Join(outs, []byte("---\n")), nil
|
|
}
|
|
|
|
// OutputToFile encodes all documents in the specified format and writes them
|
|
// to the specified output path.
|
|
//
|
|
// If format is "", it is inferred from path's file extension.
|
|
func (p *Parser) OutputToFile(path, format string) error {
|
|
if format == "" {
|
|
format = Ext(path)
|
|
}
|
|
|
|
fh, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644)
|
|
if err != nil {
|
|
return fmt.Errorf("%s: %w (%w)", path, err, ErrOutputFile)
|
|
}
|
|
|
|
defer fh.Close()
|
|
|
|
err = p.OutputToWriter(fh, format)
|
|
if err != nil {
|
|
return fmt.Errorf("%s: %s", path, err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// OutputToWriter encodes all documents in the specified format and writes them
|
|
// to the specified [io.Writer].
|
|
//
|
|
// If format is "", it defaults to "json-pretty".
|
|
func (p *Parser) OutputToWriter(fh io.Writer, format string) error {
|
|
if format == "" {
|
|
format = "json-pretty"
|
|
}
|
|
|
|
out, err := p.Output(format)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
_, err = fh.Write(out)
|
|
if err != nil {
|
|
return fmt.Errorf("%w (%w)", err, ErrOutputFile)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (p *Parser) log(format string, v ...any) {
|
|
if !p.debug {
|
|
return
|
|
}
|
|
|
|
log.Printf(format, v...)
|
|
}
|