diff --git a/README.md b/README.md index f93ec9d..35ba24c 100644 --- a/README.md +++ b/README.md @@ -174,6 +174,7 @@ Contents: - [(( &tag:name(value) ))](#-tagnamevalue-) - [(( name::path ))](#-namepath-) - [(( name::. ))](#-name-) + - [Path Resolution for Tags](#path-resolution-for-tags) - [Tags in Multi-Document Streams](#tags-in-multi-document-streams) - [Templates](#templates) - [<<: (( &template ))](#--template-) @@ -4822,6 +4823,59 @@ tagref: (( alice::. )) resolves `tagref` to `25` +### Path Resolution for Tags + +A tag reference always contains a tag name and a path separated by a double +colon (`::`). +The standard usecase is to describe a dedicated sub node for a tagged +node value. + +for example, if the tag `X` describes the value + +```yaml +data: + alice: 25 + bob: 24 +``` + +the tagged reference `tag::data.alice` describes the value `25`. + +For tagged reference with a path other the `.` (the whole tag value), +structured tags feature a more sophisticated resolution mechanism. A structured +tag consist of multiple tag components separated by a colon (`:`), for +example `lib:mylib`. +Evaluation of a path reference for a tag tries to resolve the path in the +first unique nested tag, if it cannot be resolved directly by the given tag. + +For example: + +```yaml +tags: + - <<: (( &tag:lib:alice )) + data: alice.alice + - <<: (( &tag:lib:alice:v1)) + data: alice.v1 + - <<: (( &tag:lib:bob)) + other: bob +usage: + data: (( lib::data )) +``` + +effectively resolves `usage.data` to `lib:alice::data` and therefore to the value +`alice.alice`. + +To achieve this all matching sub tags are orderd by their number of +tag components. The first sub-level tag containing such a +given path is selected. For this level, the matching tag must be non-ambigious. +There must only be one tag with this level containing a matching path. +If there are multiple ones the evaluation fails. In the above example this would +be the case if tag `lib:bob` would contain a field `data` instead of or +additional to `other`. + +This feature can be used in library stubs to provide qualified names for their +elements that can be used with merging the containing document nodes into +the template. + ### Tags in Multi-Document Streams If the template file is a multi-document stream the tags are preserved during diff --git a/cmd/merge.go b/cmd/merge.go index 24f7641..57c66b7 100644 --- a/cmd/merge.go +++ b/cmd/merge.go @@ -173,18 +173,23 @@ func merge(stdin bool, templateFilePath string, opts flow.Options, json, split b if i <= 0 { log.Fatalln(fmt.Sprintf("tag file must be preceeded by a tag (:)")) } + tagName := tagDef[:i] + err := dynaml.CheckTagName(tagName) + if err != nil { + log.Fatalln(fmt.Sprintf("invalid tag name [%s]:", path.Clean(tagName)), err) + } tagFilePath := tagDef[i+1:] tagFile, err := ReadFile(tagFilePath) if err != nil { - log.Fatalln(fmt.Sprintf("error reading tag gile [%s]:", path.Clean(tagFilePath)), err) + log.Fatalln(fmt.Sprintf("error reading tag file [%s]:", path.Clean(tagFilePath)), err) } tagYAML, err := yaml.Parse(tagFilePath, tagFile) if err != nil { - log.Fatalln(fmt.Sprintf("error parsing tag gile [%s]:", path.Clean(tagFilePath)), err) + log.Fatalln(fmt.Sprintf("error parsing tag file [%s]:", path.Clean(tagFilePath)), err) } - tags = append(tags, dynaml.NewTag(tagDef[:i], tagYAML, nil, dynaml.TAG_SCOPE_GLOBAL)) + tags = append(tags, dynaml.NewTag(tagName, tagYAML, nil, dynaml.TAG_SCOPE_GLOBAL)) } if stubs == nil { diff --git a/dynaml/call.go b/dynaml/call.go index a71c800..7f2ecea 100644 --- a/dynaml/call.go +++ b/dynaml/call.go @@ -2,9 +2,10 @@ package dynaml import ( "fmt" - "github.com/mandelsoft/spiff/yaml" "strings" + "github.com/mandelsoft/spiff/yaml" + "github.com/mandelsoft/spiff/debug" ) @@ -58,7 +59,7 @@ func (e CallExpr) Evaluate(binding Binding, locally bool) (interface{}, Evaluati var info EvaluationInfo ref, okf := e.Function.(ReferenceExpr) - if okf && len(ref.Path) == 1 && ref.Path[0] != "" && ref.Path[0] != "_" { + if okf && ref.Tag == "" && len(ref.Path) == 1 && ref.Path[0] != "" && ref.Path[0] != "_" { funcName = ref.Path[0] } else { value, info, okf = ResolveExpressionOrPushEvaluation(&e.Function, &resolved, &info, binding, false) diff --git a/dynaml/expression.go b/dynaml/expression.go index 9d73214..2a85108 100644 --- a/dynaml/expression.go +++ b/dynaml/expression.go @@ -1,8 +1,6 @@ package dynaml import ( - "fmt" - "github.com/mandelsoft/vfs/pkg/vfs" "github.com/mandelsoft/spiff/yaml" @@ -18,87 +16,6 @@ type SourceProvider interface { SourceName() string } -func CheckTagName(name string) error { - l := 0 - for _, c := range name { - switch c { - case ':': - if l == 0 { - return fmt.Errorf("empty tag component not allowed") - } - l = 0 - default: - l++ - if c >= '0' && c <= '9' { - if l == 1 { - return fmt.Errorf("tag component must start with alnum rune") - } - continue - } - if c >= 'a' && c <= 'z' { - continue - } - if c >= 'A' && c <= 'Z' { - continue - } - return fmt.Errorf("invalid character %q in tag component", string(c)) - } - } - return nil -} - -const TAG_LOCAL = TagScope(0x01) -const TAG_SCOPE = TagScope(0x06) -const TAG_SCOPE_GLOBAL = TagScope(0x00) -const TAG_SCOPE_STREAM = TagScope(0x02) - -type TagScope int - -type Tag struct { - name string - node yaml.Node - path []string - scope TagScope -} - -func NewTag(name string, node yaml.Node, path []string, scope TagScope) *Tag { - return &Tag{name, node, path, scope} -} - -func (t *Tag) Name() string { - return t.name -} - -func (t *Tag) Node() yaml.Node { - return t.node -} - -func (t *Tag) Path() []string { - return t.path -} - -func (t *Tag) Scope() TagScope { - return t.scope -} - -func (t *Tag) IsLocal() bool { - return t.scope&TAG_LOCAL != 0 -} - -func (t *Tag) IsStream() bool { - return t.scope&TAG_SCOPE == TAG_SCOPE_STREAM -} - -func (t *Tag) IsGlobal() bool { - return t.scope&TAG_SCOPE == TAG_SCOPE_GLOBAL -} - -func (t *Tag) ResetLocal() { - if t.IsLocal() { - t.scope &= ^TAG_LOCAL - } -} - type State interface { GetTempName(data []byte) (string, error) GetFileContent(file string, cached bool) ([]byte, error) @@ -110,6 +27,7 @@ type State interface { InterpolationEnabled() bool SetTag(name string, node yaml.Node, path []string, scope TagScope) error GetTag(name string) *Tag + GetTags(name string) []*TagInfo EnableInterpolation() } diff --git a/dynaml/parser_test.go b/dynaml/parser_test.go index 35f215a..2bd27c5 100644 --- a/dynaml/parser_test.go +++ b/dynaml/parser_test.go @@ -116,6 +116,18 @@ var _ = Describe("parsing", func() { }) }) + Describe("tagged expressions", func() { + It("parses tagged function", func() { + parsesAs( + `lib::func(1)`, + CallExpr{ + Function: ReferenceExpr{Tag: "lib", Path: []string{"func"}}, + Arguments: []Expression{IntegerExpr{Value: 1}}, + }, + ) + }) + }) + Describe("concatenation", func() { It("parses adjacent nodes as concatenation", func() { parsesAs( diff --git a/dynaml/reference.go b/dynaml/reference.go index 9f9ac5a..a7595a0 100644 --- a/dynaml/reference.go +++ b/dynaml/reference.go @@ -25,16 +25,59 @@ func (e ReferenceExpr) Evaluate(binding Binding, locally bool) (interface{}, Eva fromRoot := e.Path[0] == "" debug.Debug("reference: (%s)%v\n", e.Tag, e.Path) + sel := func(end int, path []string) (yaml.Node, bool) { + if fromRoot { + start := 0 + if e.Path[0] == "" { + start = 1 + } + return binding.FindFromRoot(path[start : end+1]) + } else { + if tag != nil { + return yaml.Find(tag.Node(), path...) + } + return binding.FindReference(path[:end+1]) + } + } + if e.Tag != "" { info := DefaultInfo() if e.Tag != "doc:0" { - tag = binding.GetState().GetTag(e.Tag) - if tag == nil { + tags := binding.GetState().GetTags(e.Tag) + if len(tags) == 0 { return info.Error("tag '%s' not found", e.Tag) } if len(e.Path) == 1 && e.Path[0] == "" { - return tag.Node().Value(), info, true + if len(tags) == 1 || tags[0].Name() == e.Tag { + return tags[0].Node().Value(), info, true + } + return info.Error("found multiple tags for '%s': %s", e.Tag, tagList(tags)) + } + var val interface{} + var info EvaluationInfo + var found *TagInfo + for _, t := range tags { + tag = t.Tag() + if found != nil && found.Level() < t.Level() { + break + } + val1, info1, ok1 := e.find(sel, binding, locally) + if ok1 { + if tag.Name() == e.Tag { + return val1, info1, ok1 + } + if found != nil { + if found.Level() == t.Level() { + return info.Error("ambigious tag resolution for %s: %s <-> %s", e.String(), + found.Name(), t.Name()) + } + } + found = t + val = val1 + info = info1 + } } + return val, info, found != nil } else { if len(e.Path) == 1 && e.Path[0] == "" { return info.Error("no reference to actual document possible") @@ -42,20 +85,7 @@ func (e ReferenceExpr) Evaluate(binding Binding, locally bool) (interface{}, Eva fromRoot = true } } - return e.find(func(end int, path []string) (yaml.Node, bool) { - if fromRoot { - start := 0 - if e.Path[0] == "" { - start = 1 - } - return binding.FindFromRoot(path[start : end+1]) - } else { - if tag != nil { - return yaml.Find(tag.Node(), path...) - } - return binding.FindReference(path[:end+1]) - } - }, binding, locally) + return e.find(sel, binding, locally) } func (e ReferenceExpr) String() string { @@ -103,3 +133,13 @@ func (e ReferenceExpr) find(f func(int, []string) (node yaml.Node, x bool), bind info.KeyName = step.KeyName() return value(yaml.ReferencedNode(step)), info, true } + +func tagList(list []*TagInfo) string { + s := "" + sep := "" + for _, l := range list { + s = s + sep + l.Name() + sep = ", " + } + return s +} diff --git a/dynaml/tag.go b/dynaml/tag.go new file mode 100644 index 0000000..7cb720b --- /dev/null +++ b/dynaml/tag.go @@ -0,0 +1,159 @@ +package dynaml + +import ( + "fmt" + + "github.com/mandelsoft/spiff/yaml" +) + +const TAG_LOCAL = TagScope(0x01) +const TAG_SCOPE = TagScope(0x06) +const TAG_SCOPE_GLOBAL = TagScope(0x00) +const TAG_SCOPE_STREAM = TagScope(0x02) + +type TagScope int + +type Tag struct { + name string + node yaml.Node + path []string + scope TagScope +} + +func NewTag(name string, node yaml.Node, path []string, scope TagScope) *Tag { + return &Tag{name, node, path, scope} +} + +func (t *Tag) Name() string { + return t.name +} + +func (t *Tag) Node() yaml.Node { + return t.node +} + +func (t *Tag) Path() []string { + return t.path +} + +func (t *Tag) Scope() TagScope { + return t.scope +} + +func (t *Tag) IsLocal() bool { + return t.scope&TAG_LOCAL != 0 +} + +func (t *Tag) IsStream() bool { + return t.scope&TAG_SCOPE == TAG_SCOPE_STREAM +} + +func (t *Tag) IsGlobal() bool { + return t.scope&TAG_SCOPE == TAG_SCOPE_GLOBAL +} + +func (t *Tag) ResetLocal() { + if t.IsLocal() { + t.scope &= ^TAG_LOCAL + } +} + +type TagInfo struct { + tag *Tag + level int + comps []string +} + +func (t *TagInfo) Tag() *Tag { + return t.tag +} + +func (t *TagInfo) Level() int { + return t.level +} + +func (t *TagInfo) Comps() []string { + return t.comps +} + +func (t *TagInfo) Name() string { + return t.tag.name +} + +func (t *TagInfo) Node() yaml.Node { + return t.tag.node +} + +func (t *TagInfo) Path() []string { + return t.tag.path +} + +func (t *TagInfo) Scope() TagScope { + return t.tag.scope +} + +func (t *TagInfo) IsLocal() bool { + return t.tag.IsLocal() +} + +func (t *TagInfo) IsStream() bool { + return t.tag.IsStream() +} + +func (t *TagInfo) IsGlobal() bool { + return t.tag.IsGlobal() +} + +func (t *TagInfo) ResetLocal() { + t.tag.ResetLocal() +} + +func NewTagInfo(tag *Tag) *TagInfo { + l := 0 + comp := "" + comps := []string{} + for _, c := range tag.name { + if c == ':' { + comps = append(comps, comp) + comp = "" + l++ + } else { + comp += string(c) + } + } + comps = append(comps, comp) + return &TagInfo{ + tag: tag, + level: l, + comps: comps, + } +} + +func CheckTagName(name string) error { + l := 0 + for _, c := range name { + switch c { + case ':': + if l == 0 { + return fmt.Errorf("empty tag component not allowed") + } + l = 0 + default: + l++ + if c >= '0' && c <= '9' { + if l == 1 { + return fmt.Errorf("tag component must start with alnum rune") + } + continue + } + if c >= 'a' && c <= 'z' { + continue + } + if c >= 'A' && c <= 'Z' { + continue + } + return fmt.Errorf("invalid character %q in tag component", string(c)) + } + } + return nil +} diff --git a/flow/flow_tag_test.go b/flow/flow_tag_test.go index 18702e6..f5b7533 100644 --- a/flow/flow_tag_test.go +++ b/flow/flow_tag_test.go @@ -182,4 +182,66 @@ data: )) }) }) + + Context("Tag Scopes", func() { + It("propagates tag content", func() { + source := parseYAML(` +--- +tags: + - <<: (( &temporary )) + - <<: (( &tag:lib:alice )) + data: alice.alice + - <<: (( &tag:lib:alice:v1 )) + data: alice.v1 +usage: + data: (( lib::data )) +`) + resolved := parseYAML(` +--- +usage: + data: alice.alice +`) + Expect(source).To(FlowAs(resolved)) + }) + It("detects nonuniqe path resolution", func() { + source := parseYAML(` +--- +tags: + - <<: (( &temporary )) + - <<: (( &tag:lib:alice )) + data: alice.alice + - <<: (( &tag:lib:bob)) + data: bob +usage: + data: (( catch(lib::data) )) +`) + resolved := parseYAML(` +--- +usage: + data: + error: 'ambigious tag resolution for lib::data: lib:alice <-> lib:bob' + valid: false +`) + Expect(source).To(FlowAs(resolved)) + }) + + It("handles context", func() { + source := parseYAML(` +--- +tags: + - <<: (( &temporary )) + - <<: (( &tag:lib:alice )) + func: (( |x|->x * _.multiplier )) + multiplier: 2 +usage: + data: (( lib::func(2) )) +`) + resolved := parseYAML(` +--- +usage: + data: 4 +`) + Expect(source).To(FlowAs(resolved)) + }) + }) }) diff --git a/flow/state.go b/flow/state.go index 4a92d9d..7beda9e 100644 --- a/flow/state.go +++ b/flow/state.go @@ -8,6 +8,7 @@ import ( "net/http" "path" "reflect" + "sort" "strconv" "strings" @@ -31,7 +32,7 @@ type State struct { fileSystem vfs.VFS // virtual filesystem to use for filesystem based operations functions dynaml.Registry interpolation bool - tags map[string]*dynaml.Tag + tags map[string]*dynaml.TagInfo docno int // document number } @@ -48,7 +49,7 @@ func NewState(key string, mode int, optfs ...vfs.FileSystem) *State { mode = mode & ^MODE_OS_ACCESS } return &State{ - tags: map[string]*dynaml.Tag{}, + tags: map[string]*dynaml.TagInfo{}, files: map[string]string{}, fileCache: map[string][]byte{}, key: key, @@ -68,9 +69,9 @@ func (s *State) SetFunctions(f dynaml.Registry) *State { } func (s *State) SetTags(tags ...*dynaml.Tag) *State { - s.tags = map[string]*dynaml.Tag{} + s.tags = map[string]*dynaml.TagInfo{} for _, v := range tags { - s.tags[v.Name()] = v + s.tags[v.Name()] = dynaml.NewTagInfo(v) } return s } @@ -138,7 +139,7 @@ func (s *State) SetTag(name string, node yaml.Node, path []string, scope dynaml. return fmt.Errorf("duplicate tag %q: %s <-> %s", name, strings.Join(path, "."), strings.Join(old.Path(), ".")) } } - s.tags[name] = dynaml.NewTag(name, Cleanup(node, discardTags), path, scope) + s.tags[name] = dynaml.NewTagInfo(dynaml.NewTag(name, Cleanup(node, discardTags), path, scope)) return nil } @@ -156,16 +157,56 @@ func (s *State) GetTag(name string) *dynaml.Tag { name = fmt.Sprintf("doc:%d", i) } } - return s.tags[name] + tag := s.tags[name] + if tag == nil { + return nil + } + return tag.Tag() +} + +func (s *State) GetTags(name string) []*dynaml.TagInfo { + if strings.HasPrefix(name, "doc:") { + i, err := strconv.Atoi(name[4:]) + if err != nil { + return nil + } + if i <= 0 { + i += s.docno + if i <= 0 { + return nil + } + name = fmt.Sprintf("doc:%d", i) + } + tag := s.tags[name] + if tag == nil { + return nil + } + return []*dynaml.TagInfo{tag} + } + + var list []*dynaml.TagInfo + prefix := name + ":" + for _, t := range s.tags { + if t.Name() == name || strings.HasPrefix(t.Name(), prefix) { + list = append(list, t) + } + } + sort.Slice(list, func(i, j int) bool { + if list[i].Level() != list[j].Level() { + return list[i].Level() < list[j].Level() + } + return strings.Compare(list[i].Name(), list[j].Name()) < 0 + }) + return list } func (s *State) ResetTags() { - s.tags = map[string]*dynaml.Tag{} + s.tags = map[string]*dynaml.TagInfo{} s.docno = 1 } func (s *State) ResetStream() { - n := map[string]*dynaml.Tag{} + n := map[string]*dynaml.TagInfo{} for _, v := range s.tags { if !v.IsStream() { n[v.Name()] = v diff --git a/spiffing/spiff.go b/spiffing/spiff.go index 639e3fb..a57e7f1 100644 --- a/spiffing/spiff.go +++ b/spiffing/spiff.go @@ -183,7 +183,7 @@ func (s spiff) WithValues(values map[string]interface{}) (Spiff, error) { // SetTag sets/resets a global tag for subsequent processings. func (s spiff) SetTag(tag string, node yaml.Node) Spiff { - s.tags[tag] = dynaml.NewTag(tag, node, nil, true) + s.tags[tag] = dynaml.NewTag(tag, node, nil, dynaml.TAG_SCOPE_GLOBAL) return s.Reset() }