hcl: Best-effort "what's at this position" helpers

When building tools around HCL configuration files it is useful to be
able to ask what is present at a given position in a file. This set of
new helper functions provide a best-effort implementation of this for
the native syntax only.

It cannot be supported for JSON syntax with these signatures because the
JSON syntax is ambiguous and thus can't be interpreted without a schema
for each structural level. In practice this is not a big loss because
JSON files will usually be generated rather than hand-written anyway, and
so doing automatic analysis and transformation of them would not be
useful: the program that generated the file must be updated instead.
This commit is contained in:
Martin Atkins 2018-07-28 13:17:51 -07:00
parent 93562f805f
commit 956c336d40
5 changed files with 587 additions and 0 deletions

View File

@ -9,6 +9,10 @@ import (
// AsHCLBlock returns the block data expressed as a *hcl.Block.
func (b *Block) AsHCLBlock() *hcl.Block {
if b == nil {
return nil
}
lastHeaderRange := b.TypeRange
if len(b.LabelRanges) > 0 {
lastHeaderRange = b.LabelRanges[len(b.LabelRanges)-1]
@ -326,6 +330,9 @@ func (a *Attribute) Range() hcl.Range {
// AsHCLAttribute returns the block data expressed as a *hcl.Attribute.
func (a *Attribute) AsHCLAttribute() *hcl.Attribute {
if a == nil {
return nil
}
return &hcl.Attribute{
Name: a.Name,
Expr: a.Expr,

View File

@ -0,0 +1,118 @@
package hclsyntax
import (
"github.com/hashicorp/hcl2/hcl"
)
// -----------------------------------------------------------------------------
// The methods in this file are all optional extension methods that serve to
// implement the methods of the same name on *hcl.File when its root body
// is provided by this package.
// -----------------------------------------------------------------------------
// BlocksAtPos implements the method of the same name for an *hcl.File that
// is backed by a *Body.
func (b *Body) BlocksAtPos(pos hcl.Pos) []*hcl.Block {
list, _ := b.blocksAtPos(pos, true)
return list
}
// InnermostBlockAtPos implements the method of the same name for an *hcl.File
// that is backed by a *Body.
func (b *Body) InnermostBlockAtPos(pos hcl.Pos) *hcl.Block {
_, innermost := b.blocksAtPos(pos, false)
return innermost.AsHCLBlock()
}
// OutermostBlockAtPos implements the method of the same name for an *hcl.File
// that is backed by a *Body.
func (b *Body) OutermostBlockAtPos(pos hcl.Pos) *hcl.Block {
return b.outermostBlockAtPos(pos).AsHCLBlock()
}
// blocksAtPos is the internal engine of both BlocksAtPos and
// InnermostBlockAtPos, which both need to do the same logic but return a
// differently-shaped result.
//
// list is nil if makeList is false, avoiding an allocation. Innermost is
// always set, and if the returned list is non-nil it will always match the
// final element from that list.
func (b *Body) blocksAtPos(pos hcl.Pos, makeList bool) (list []*hcl.Block, innermost *Block) {
current := b
Blocks:
for current != nil {
for _, block := range current.Blocks {
wholeRange := hcl.RangeBetween(block.TypeRange, block.CloseBraceRange)
if wholeRange.ContainsPos(pos) {
innermost = block
if makeList {
list = append(list, innermost.AsHCLBlock())
}
current = block.Body
continue Blocks
}
}
// If we fall out here then none of the current body's nested blocks
// contain the position we are looking for, and so we're done.
break
}
return
}
// outermostBlockAtPos is the internal version of OutermostBlockAtPos that
// returns a hclsyntax.Block rather than an hcl.Block, allowing for further
// analysis if necessary.
func (b *Body) outermostBlockAtPos(pos hcl.Pos) *Block {
// This is similar to blocksAtPos, but simpler because we know it only
// ever needs to search the first level of nested blocks.
for _, block := range b.Blocks {
wholeRange := hcl.RangeBetween(block.TypeRange, block.CloseBraceRange)
if wholeRange.ContainsPos(pos) {
return block
}
}
return nil
}
// AttributeAtPos implements the method of the same name for an *hcl.File
// that is backed by a *Body.
func (b *Body) AttributeAtPos(pos hcl.Pos) *hcl.Attribute {
return b.attributeAtPos(pos).AsHCLAttribute()
}
// attributeAtPos is the internal version of AttributeAtPos that returns a
// hclsyntax.Block rather than an hcl.Block, allowing for further analysis if
// necessary.
func (b *Body) attributeAtPos(pos hcl.Pos) *Attribute {
searchBody := b
_, block := b.blocksAtPos(pos, false)
if block != nil {
searchBody = block.Body
}
for _, attr := range searchBody.Attributes {
if attr.SrcRange.ContainsPos(pos) {
return attr
}
}
return nil
}
// OutermostExprAtPos implements the method of the same name for an *hcl.File
// that is backed by a *Body.
func (b *Body) OutermostExprAtPos(pos hcl.Pos) hcl.Expression {
attr := b.attributeAtPos(pos)
if attr == nil {
return nil
}
if !attr.Expr.Range().ContainsPos(pos) {
return nil
}
return attr.Expr
}

View File

@ -0,0 +1,335 @@
package hclsyntax
import (
"reflect"
"testing"
"github.com/hashicorp/hcl2/hcl"
)
func TestBlocksAtPos(t *testing.T) {
tests := map[string]struct {
Src string
Pos hcl.Pos
WantTypes []string
}{
"empty": {
``,
hcl.Pos{Byte: 0},
nil,
},
"spaces": {
` `,
hcl.Pos{Byte: 1},
nil,
},
"single in header": {
`foo {}`,
hcl.Pos{Byte: 1},
[]string{"foo"},
},
"single in body": {
`foo { }`,
hcl.Pos{Byte: 7},
[]string{"foo"},
},
"single in body with unselected nested": {
`
foo {
bar {
}
}
`,
hcl.Pos{Byte: 10},
[]string{"foo"},
},
"single in body with unselected sibling": {
`
foo { }
bar { }
`,
hcl.Pos{Byte: 10},
[]string{"foo"},
},
"selected nested two levels": {
`
foo {
bar {
}
}
`,
hcl.Pos{Byte: 20},
[]string{"foo", "bar"},
},
"selected nested three levels": {
`
foo {
bar {
baz {
}
}
}
`,
hcl.Pos{Byte: 31},
[]string{"foo", "bar", "baz"},
},
"selected nested three levels with unselected sibling after": {
`
foo {
bar {
baz {
}
}
not_wanted {}
}
`,
hcl.Pos{Byte: 31},
[]string{"foo", "bar", "baz"},
},
"selected nested three levels with unselected sibling before": {
`
foo {
not_wanted {}
bar {
baz {
}
}
}
`,
hcl.Pos{Byte: 49},
[]string{"foo", "bar", "baz"},
},
"unterminated": {
`foo { `,
hcl.Pos{Byte: 7},
[]string{"foo"},
},
"unterminated nested": {
`
foo {
bar {
}
`,
hcl.Pos{Byte: 16},
[]string{"foo", "bar"},
},
}
for name, test := range tests {
t.Run(name, func(t *testing.T) {
f, diags := ParseConfig([]byte(test.Src), "", hcl.Pos{Line: 1, Column: 1})
for _, diag := range diags {
// We intentionally ignore diagnostics here because we should be
// able to work with the incomplete configuration that results
// when the parser does its recovery behavior. However, we do
// log them in case it's helpful to someone debugging a failing
// test.
t.Logf(diag.Error())
}
blocks := f.BlocksAtPos(test.Pos)
outermost := f.OutermostBlockAtPos(test.Pos)
innermost := f.InnermostBlockAtPos(test.Pos)
gotTypes := make([]string, len(blocks))
for i, block := range blocks {
gotTypes[i] = block.Type
}
if len(test.WantTypes) == 0 {
if len(gotTypes) != 0 {
t.Errorf("wrong block types\ngot: %#v\nwant: (none)", gotTypes)
}
if outermost != nil {
t.Errorf("wrong outermost type\ngot: %#v\nwant: (none)", outermost.Type)
}
if innermost != nil {
t.Errorf("wrong innermost type\ngot: %#v\nwant: (none)", innermost.Type)
}
return
}
if !reflect.DeepEqual(gotTypes, test.WantTypes) {
if len(gotTypes) != 0 {
t.Errorf("wrong block types\ngot: %#v\nwant: %#v", gotTypes, test.WantTypes)
}
}
if got, want := outermost.Type, test.WantTypes[0]; got != want {
t.Errorf("wrong outermost type\ngot: %#v\nwant: %#v", got, want)
}
if got, want := innermost.Type, test.WantTypes[len(test.WantTypes)-1]; got != want {
t.Errorf("wrong innermost type\ngot: %#v\nwant: %#v", got, want)
}
})
}
}
func TestAttributeAtPos(t *testing.T) {
tests := map[string]struct {
Src string
Pos hcl.Pos
WantName string
}{
"empty": {
``,
hcl.Pos{Byte: 0},
"",
},
"top-level": {
`foo = 1`,
hcl.Pos{Byte: 0},
"foo",
},
"top-level with ignored sibling after": {
`
foo = 1
bar = 2
`,
hcl.Pos{Byte: 6},
"foo",
},
"top-level ignored sibling before": {
`
foo = 1
bar = 2
`,
hcl.Pos{Byte: 17},
"bar",
},
"nested": {
`
foo {
bar = 2
}
`,
hcl.Pos{Byte: 17},
"bar",
},
"nested in unterminated block": {
`
foo {
bar = 2
`,
hcl.Pos{Byte: 17},
"bar",
},
}
for name, test := range tests {
t.Run(name, func(t *testing.T) {
f, diags := ParseConfig([]byte(test.Src), "", hcl.Pos{Line: 1, Column: 1})
for _, diag := range diags {
// We intentionally ignore diagnostics here because we should be
// able to work with the incomplete configuration that results
// when the parser does its recovery behavior. However, we do
// log them in case it's helpful to someone debugging a failing
// test.
t.Logf(diag.Error())
}
got := f.AttributeAtPos(test.Pos)
if test.WantName == "" {
if got != nil {
t.Errorf("wrong attribute name\ngot: %#v\nwant: (none)", got.Name)
}
return
}
if got == nil {
t.Fatalf("wrong attribute name\ngot: (none)\nwant: %#v", test.WantName)
}
if got.Name != test.WantName {
t.Errorf("wrong attribute name\ngot: %#v\nwant: %#v", got.Name, test.WantName)
}
})
}
}
func TestOutermostExprAtPos(t *testing.T) {
tests := map[string]struct {
Src string
Pos hcl.Pos
WantSrc string
}{
"empty": {
``,
hcl.Pos{Byte: 0},
``,
},
"simple bool": {
`a = true`,
hcl.Pos{Byte: 6},
`true`,
},
"simple reference": {
`a = blah`,
hcl.Pos{Byte: 6},
`blah`,
},
"attribute reference": {
`a = blah.foo`,
hcl.Pos{Byte: 6},
`blah.foo`,
},
"parens": {
`a = (1 + 1)`,
hcl.Pos{Byte: 6},
`1 + 1`, // The parser trims the parens off, so they aren't considered as part of the expression :(
},
"tuple cons": {
`a = [1, 2, 3]`,
hcl.Pos{Byte: 5},
`[1, 2, 3]`,
},
"function call": {
`a = foom("a")`,
hcl.Pos{Byte: 10},
`foom("a")`,
},
}
for name, test := range tests {
t.Run(name, func(t *testing.T) {
inputSrc := []byte(test.Src)
f, diags := ParseConfig(inputSrc, "", hcl.Pos{Line: 1, Column: 1})
for _, diag := range diags {
// We intentionally ignore diagnostics here because we should be
// able to work with the incomplete configuration that results
// when the parser does its recovery behavior. However, we do
// log them in case it's helpful to someone debugging a failing
// test.
t.Logf(diag.Error())
}
gotExpr := f.OutermostExprAtPos(test.Pos)
var gotSrc string
if gotExpr != nil {
rng := gotExpr.Range()
gotSrc = string(rng.SliceBytes(inputSrc))
}
if test.WantSrc == "" {
if gotExpr != nil {
t.Errorf("wrong expression source\ngot: %s\nwant: (none)", gotSrc)
}
return
}
if gotExpr == nil {
t.Fatalf("wrong expression source\ngot: (none)\nwant: %s", test.WantSrc)
}
if gotSrc != test.WantSrc {
t.Errorf("wrong expression source\ngot: %#v\nwant: %#v", gotSrc, test.WantSrc)
}
})
}
}

View File

@ -94,6 +94,16 @@ func RangeOver(a, b Range) Range {
}
}
// ContainsPos returns true if and only if the given position is contained within
// the receiving range.
//
// In the unlikely case that the line/column information disagree with the byte
// offset information in the given position or receiving range, the byte
// offsets are given priority.
func (r Range) ContainsPos(pos Pos) bool {
return r.ContainsOffset(pos.Byte)
}
// ContainsOffset returns true if and only if the given byte offset is within
// the receiving Range.
func (r Range) ContainsOffset(offset int) bool {

117
hcl/structure_at_pos.go Normal file
View File

@ -0,0 +1,117 @@
package hcl
// -----------------------------------------------------------------------------
// The methods in this file all have the general pattern of making a best-effort
// to find one or more constructs that contain a given source position.
//
// These all operate by delegating to an optional method of the same name and
// signature on the file's root body, allowing each syntax to potentially
// provide its own implementations of these. For syntaxes that don't implement
// them, the result is always nil.
// -----------------------------------------------------------------------------
// BlocksAtPos attempts to find all of the blocks that contain the given
// position, ordered so that the outermost block is first and the innermost
// block is last. This is a best-effort method that may not be able to produce
// a complete result for all positions or for all HCL syntaxes.
//
// If the returned slice is non-empty, the first element is guaranteed to
// represent the same block as would be the result of OutermostBlockAtPos and
// the last element the result of InnermostBlockAtPos. However, the
// implementation may return two different objects describing the same block,
// so comparison by pointer identity is not possible.
//
// The result is nil if no blocks at all contain the given position.
func (f *File) BlocksAtPos(pos Pos) []*Block {
// The root body of the file must implement this interface in order
// to support BlocksAtPos.
type Interface interface {
BlocksAtPos(pos Pos) []*Block
}
impl, ok := f.Body.(Interface)
if !ok {
return nil
}
return impl.BlocksAtPos(pos)
}
// OutermostBlockAtPos attempts to find a top-level block in the receiving file
// that contains the given position. This is a best-effort method that may not
// be able to produce a result for all positions or for all HCL syntaxes.
//
// The result is nil if no single block could be selected for any reason.
func (f *File) OutermostBlockAtPos(pos Pos) *Block {
// The root body of the file must implement this interface in order
// to support OutermostBlockAtPos.
type Interface interface {
OutermostBlockAtPos(pos Pos) *Block
}
impl, ok := f.Body.(Interface)
if !ok {
return nil
}
return impl.OutermostBlockAtPos(pos)
}
// InnermostBlockAtPos attempts to find the most deeply-nested block in the
// receiving file that contains the given position. This is a best-effort
// method that may not be able to produce a result for all positions or for
// all HCL syntaxes.
//
// The result is nil if no single block could be selected for any reason.
func (f *File) InnermostBlockAtPos(pos Pos) *Block {
// The root body of the file must implement this interface in order
// to support InnermostBlockAtPos.
type Interface interface {
InnermostBlockAtPos(pos Pos) *Block
}
impl, ok := f.Body.(Interface)
if !ok {
return nil
}
return impl.InnermostBlockAtPos(pos)
}
// OutermostExprAtPos attempts to find an expression in the receiving file
// that contains the given position. This is a best-effort method that may not
// be able to produce a result for all positions or for all HCL syntaxes.
//
// Since expressions are often nested inside one another, this method returns
// the outermost "root" expression that is not contained by any other.
//
// The result is nil if no single expression could be selected for any reason.
func (f *File) OutermostExprAtPos(pos Pos) Expression {
// The root body of the file must implement this interface in order
// to support OutermostExprAtPos.
type Interface interface {
OutermostExprAtPos(pos Pos) Expression
}
impl, ok := f.Body.(Interface)
if !ok {
return nil
}
return impl.OutermostExprAtPos(pos)
}
// AttributeAtPos attempts to find an attribute definition in the receiving
// file that contains the given position. This is a best-effort method that may
// not be able to produce a result for all positions or for all HCL syntaxes.
//
// The result is nil if no single attribute could be selected for any reason.
func (f *File) AttributeAtPos(pos Pos) *Attribute {
// The root body of the file must implement this interface in order
// to support OutermostExprAtPos.
type Interface interface {
AttributeAtPos(pos Pos) *Attribute
}
impl, ok := f.Body.(Interface)
if !ok {
return nil
}
return impl.AttributeAtPos(pos)
}