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/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) { return this.ParseRelative(name, nil) } // ParseRelative is like Parse, but treats the name as a path relative to the // given document. func (this *Environment) ParseRelative (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.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 := SplitMeta(string(buffer)) if err != nil { return nil, err } // assemble the document struct document := &Document { Meta: 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 } // add template functions funcMap := make(template.FuncMap) for name, function := range this.funcMap { funcMap[name] = function } for _, provider := range this.FuncProviders { 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 }