step/environment.go

189 lines
5.4 KiB
Go

package step
import "os"
import "io"
import "fmt"
import "time"
import "io/fs"
import "errors"
import "context"
import "path/filepath"
import "html/template"
import "git.tebibyte.media/sashakoshka/go-util/sync"
// TODO change Parse to Load, ParseReader to Parse
// Environment is an execution environment for STEP documents. It can parse
// documents into itself and resolve dependencies among them.
type Environment struct {
// FS, if specified, is the filesystem that this environment will load
// documents from. Its methods must be safe for concurrent execution.
FS fs.FS
// Providers is a slice of objects which can provide various
// functionality to the environment. In order to be used, values in this
// slice must implement one or more of the interfaces in provider.go.
Providers []Provider
// Config specifies configuration data. It is used to configure the
// environment, as well as any providers that are loaded.
Config Meta
documents usync.Monitor[map[string] *Document]
funcMap template.FuncMap
}
// Init must be called before utilizng the environment.
func (this *Environment) Init (ctx context.Context) error {
this.documents = usync.NewMonitor(make(map[string] *Document))
this.funcMap = make(template.FuncMap)
for _, provider := range this.Providers {
if provider, ok := provider.(ConfigProcessor); ok {
err := provider.ProcessConfig(this.Config)
if err != nil { return err }
}
}
for _, provider := range this.Providers {
if provider, ok := provider.(Configurable); ok {
err := provider.Configure(this.Config)
if err != nil { return err }
}
if provider, ok := provider.(FuncProvider); ok {
funcMap := provider.FuncMap()
if funcMap == nil { continue }
for name, function := range funcMap {
this.funcMap[name] = function
}
}
}
return ctx.Err()
}
// Load loads the named document and returns it. The name is treated as a file
// path relative to the current working directory. Documents are cached into the
// environment once loaded, and are returned on subsequent calls that have the
// same name.
func (this *Environment) Load (name string) (*Document, error) {
return this.LoadRelative(name, nil)
}
// LoadRelative is like Load, but treats the name as a path relative to the
// given document.
func (this *Environment) LoadRelative (name string, document *Document) (*Document, error) {
if document == nil {
name = filepath.Clean(name)
} else {
nam, err := document.Rel(name)
if err != nil { return nil, err }
name = nam
}
input, err := this.GetFS().Open(name)
if err != nil {
// if the file couldn't be opened, delete from cache in case the
// file was deleted
this.Unload(name)
return nil, err
}
defer input.Close()
info, err := input.Stat()
if err != nil { return nil, err }
return this.load(name, info.ModTime(), input)
}
// Parse is like Load, but parses a reader and just takes your word for it that
// it corresponds to the file of the given name. It always re-parses.
func (this *Environment) Parse (name string, input io.Reader) (*Document, error) {
name = filepath.Clean(name)
return this.load(name, time.Now(), input)
}
// Unload removes a named document. It will be reloaded if necessary.
func (this *Environment) Unload (name string) {
documents, done := this.documents.Borrow()
defer done()
delete(documents, name)
}
func (this *Environment) load (name string, modTime time.Time, input io.Reader) (*Document, error) {
documents, done := this.documents.Borrow()
defer done()
// check cache
if document, ok := documents[name]; ok {
if modTime.Before(document.parseTime) {
return document, nil
}
}
// read entire file and split into front matter and body
buffer, err := io.ReadAll(input)
if err != nil { return nil, err }
meta, body, err := SplitMeta(string(buffer))
if err != nil { return nil, err }
// assemble the document struct
document := &Document {
Meta: meta,
environment: this,
name: name,
parseTime: time.Now(),
template: template.New(name),
}
if author := meta.Get("author"); author != "" {
document.Author = author
}
if title := meta.Get("title"); title != "" {
document.Title = title
}
if extends := meta.Get("extends"); extends != "" {
document.Extends = extends
}
// add template functions
funcMap := make(template.FuncMap)
for name, function := range this.funcMap {
funcMap[name] = function
}
for _, provider := range this.Providers {
provider, ok := provider.(FuncProviderFor)
if !ok { continue }
funcMapFor := provider.FuncMapFor(document)
if funcMapFor == nil { continue }
for name, function := range funcMapFor {
funcMap[name] = function
}
}
err = this.addFuncsTo(document, funcMap)
if err != nil { return nil, err }
// parse template from the body
// TODO catch template errors here and offset their row number by the
// number of rows taken up by the front matter
_, err = document.template.Parse(body)
if err != nil { return nil, err }
documents[name] = document
return document, nil
}
func (this *Environment) addFuncsTo (document *Document, funcs template.FuncMap) (err error) {
defer func () {
if r := recover(); r != nil {
if rerr, ok := err.(error); ok {
err = rerr
} else {
err = errors.New(fmt.Sprint(r))
}
}
} ()
document.template.Funcs(funcs)
return err
}
// GetFS returns the effective filesystem that the environment uses.
func (this *Environment) GetFS () fs.FS {
if this.FS == nil {
return os.DirFS(".")
}
return this.FS
}