step/environment.go
2024-12-07 03:13:58 -05:00

139 lines
4.1 KiB
Go

package step
import "os"
import "io"
import "time"
import "io/fs"
import "context"
import "path/filepath"
import "html/template"
import "git.tebibyte.media/sashakoshka/goutil/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
// FuncProviders is a slice of FuncMap providers whoose functions will
// be available to any documents this environment processes.
FuncProviders []FuncProvider
documents usync.Locker[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.NewLocker(make(map[string] *Document))
this.funcMap = make(template.FuncMap)
for _, provider := range this.FuncProviders {
funcMap := provider.FuncMap()
if funcMap == nil { continue }
for name, function := range funcMap {
this.funcMap[name] = function
}
}
return ctx.Err()
}
// Parse parses 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 parsed, and are returned on subsequent calls that have the
// same name.
func (this *Environment) Parse (name string) (*Document, error) {
name = filepath.Clean(name)
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.parse(name, info.ModTime(), input)
}
// ParseReader is like Parse, 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) ParseReader (name string, input io.Reader) (*Document, error) {
name = filepath.Clean(name)
return this.parse(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) parse (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 }
frontMatter, body, err := SplitFrontMatter(string(buffer))
if err != nil { return nil, err }
// assemble the document struct
document := &Document {
FrontMatter: frontMatter,
environment: this,
name: name,
parseTime: time.Now(),
template: template.New(name),
}
if author, ok := frontMatter["author"]; ok {
document.Author = author
}
if title, ok := frontMatter["title"]; ok {
document.Title = title
}
if extends, ok := frontMatter["extends"]; ok {
document.Extends = extends
}
// parse template from the body
document.template.Funcs(this.funcMap)
// 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 }
// add template functions which need a document pointer for context
for _, provider := range this.FuncProviders {
provider, ok := provider.(FuncProviderFor)
if !ok { continue }
funcMap := provider.FuncMapFor(document)
if funcMap == nil { continue }
for name, function := range funcMap {
this.funcMap[name] = function
}
}
documents[name] = document
return document, nil
}
// 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
}