Skip to content

Commit

Permalink
Name allowed keys and/guess on missing map key (#547)
Browse files Browse the repository at this point in the history
* Name allowed keys and/guess on missing map key

* Attribute "Nearest" to Starlark authors

* Adjust format to make more readable with 2+ errors

Co-authored-by: John Ryan <jtigger@infosysengr.com>
  • Loading branch information
pivotaljohn and jtigger committed Nov 18, 2021
1 parent 0c8d4b9 commit c8ed5cd
Show file tree
Hide file tree
Showing 5 changed files with 169 additions and 28 deletions.
22 changes: 11 additions & 11 deletions pkg/cmd/template/schema_author_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -190,8 +190,8 @@ foo: 0
expectedErr := `
Invalid schema
==============
unknown @schema/type annotation keyword argument
unknown @schema/type annotation keyword argument
schema.yml:
|
4 | foo: 0
Expand All @@ -218,8 +218,8 @@ foo: 0
expectedErr := `
Invalid schema
==============
unknown @schema/type annotation keyword argument
unknown @schema/type annotation keyword argument
schema.yml:
|
4 | foo: 0
Expand All @@ -246,8 +246,8 @@ foo: 0
expectedErr := `
Invalid schema
==============
expected @schema/type annotation to have keyword argument and value
expected @schema/type annotation to have keyword argument and value
schema.yml:
|
4 | foo: 0
Expand Down Expand Up @@ -286,8 +286,8 @@ foo: 0
expectedErr := `
Invalid schema
==============
@schema/nullable, and @schema/type any=True are mutually exclusive
@schema/nullable, and @schema/type any=True are mutually exclusive
schema.yml:
|
5 | foo: 0
Expand Down Expand Up @@ -315,8 +315,8 @@ foo: 0
expectedErr := `
Invalid schema
==============
syntax error in @schema/default annotation
syntax error in @schema/default annotation
schema.yml:
|
4 | foo: 0
Expand All @@ -341,8 +341,8 @@ foo: 0
expectedErr := `
Invalid schema
==============
syntax error in @schema/default annotation
syntax error in @schema/default annotation
schema.yml:
|
4 | foo: 0
Expand All @@ -367,8 +367,8 @@ foo: 0
expectedErr := `
Invalid schema
==============
syntax error in @schema/default annotation
syntax error in @schema/default annotation
schema.yml:
|
4 | foo: 0
Expand Down Expand Up @@ -485,8 +485,8 @@ key: val
expectedErr := `
Invalid schema
==============
syntax error in @schema/desc annotation
syntax error in @schema/desc annotation
schema.yml:
|
4 | key: val
Expand All @@ -511,8 +511,8 @@ key: val
expectedErr := `
Invalid schema
==============
syntax error in @schema/desc annotation
syntax error in @schema/desc annotation
schema.yml:
|
4 | key: val
Expand All @@ -537,8 +537,8 @@ key: val
expectedErr := `
Invalid schema
==============
syntax error in @schema/desc annotation
syntax error in @schema/desc annotation
schema.yml:
|
4 | key: val
Expand All @@ -563,8 +563,8 @@ key: val
expectedErr := `
Invalid schema
==============
syntax error in @schema/desc annotation
syntax error in @schema/desc annotation
schema.yml:
|
4 | key: val
Expand Down
8 changes: 4 additions & 4 deletions pkg/cmd/template/schema_consumer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -269,14 +269,14 @@ foo:
One or more data values were invalid
====================================
Given data value is not declared in schema
dvs1.yml:
|
3 | wrong_key: not right key
|
= found: wrong_key
= expected: (a key defined in map) (by schema.yml:3)
= hint: declare data values in schema and override them in a data values document
= expected: a map item with the key named "bar" (from schema.yml:3)
`
assertFails(t, filesToProcess, expectedErrMsg, opts)
})
Expand Down Expand Up @@ -829,14 +829,14 @@ rendered: true`
One or more data values were invalid
====================================
Given data value is not declared in schema
dvs1.yml:
|
2 | not_in_schema: this should be the only violation reported
|
= found: not_in_schema
= expected: (a key defined in map) (by schema.yml:2)
= hint: declare data values in schema and override them in a data values document
= expected: a map item with the key named "hostname" (from schema.yml:2)
`
assertFails(t, filesToProcess, expectedErrMsg, opts)
})
Expand Down
38 changes: 26 additions & 12 deletions pkg/schema/error.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,31 +7,30 @@ import (
"bytes"
"fmt"
"log"
"sort"
"strings"
"text/template"

"github.com/k14s/ytt/pkg/filepos"
"github.com/k14s/ytt/pkg/spell"
"github.com/k14s/ytt/pkg/yamlmeta"
)

const schemaErrorReportTemplate = `
{{- if .Summary}}
{{.Summary}}
{{addBreak .Summary}}
{{- end}}
{{ end}}
{{- range .AssertionFailures}}
{{- if .Description}}
{{.Description}}
{{- end}}
{{- if .FromMemory}}
{{.SourceName}}:
{{pad "#" ""}}
{{pad "#" ""}} {{.Source}}
{{pad "#" ""}}
{{- else}}
{{.FileName}}:
{{pad "|" ""}}
{{pad "|" .FilePos}} {{.Source}}
Expand All @@ -43,8 +42,8 @@ const schemaErrorReportTemplate = `
{{- range .Hints}}
{{pad "=" ""}} hint: {{.}}
{{- end}}
{{- end}}
{{.MiscErrorMessage}}
{{end}}
{{- .MiscErrorMessage}}
`

func NewSchemaError(summary string, errs ...error) error {
Expand Down Expand Up @@ -95,13 +94,28 @@ func NewMismatchedTypeAssertionError(foundType yamlmeta.TypeWithValues, expected
}
}

func NewUnexpectedKeyAssertionError(found *yamlmeta.MapItem, definition *filepos.Position) error {
return schemaAssertionError{
position: found.GetPosition(),
expected: fmt.Sprintf("(a key defined in map) (by %s)", definition.AsCompactString()),
found: fmt.Sprintf("%v", found.Key),
hints: []string{"declare data values in schema and override them in a data values document"},
// NewUnexpectedKeyAssertionError generates a schema assertion error including the context (and hints) needed to report it to the user
func NewUnexpectedKeyAssertionError(found *yamlmeta.MapItem, definition *filepos.Position, allowedKeys []string) error {
key := fmt.Sprintf("%v", found.Key)
err := schemaAssertionError{
description: "Given data value is not declared in schema",
position: found.GetPosition(),
found: key,
}
sort.Strings(allowedKeys)
switch numKeys := len(allowedKeys); {
case numKeys == 1:
err.expected = fmt.Sprintf(`a %s with the key named "%s" (from %s)`, found.DisplayName(), allowedKeys[0], definition.AsCompactString())
case numKeys > 1 && numKeys <= 9: // Miller's Law
err.expected = fmt.Sprintf("one of { %s } (from %s)", strings.Join(allowedKeys, ", "), definition.AsCompactString())
default:
err.expected = fmt.Sprintf("a key declared in map (from %s)", definition.AsCompactString())
}
mostSimilarKey := spell.Nearest(key, allowedKeys)
if mostSimilarKey != "" {
err.hints = append(err.hints, fmt.Sprintf(`did you mean "%s"?`, mostSimilarKey))
}
return err
}

type schemaError struct {
Expand Down
13 changes: 12 additions & 1 deletion pkg/schema/type.go
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,7 @@ func (m *MapType) CheckType(node yamlmeta.TypeWithValues) (chk yamlmeta.TypeChec
for _, item := range nodeMap.Items {
if !m.AllowsKey(item.Key) {
chk.Violations = append(chk.Violations,
NewUnexpectedKeyAssertionError(item, m.Position))
NewUnexpectedKeyAssertionError(item, m.Position, m.AllowedKeys()))
}
}
return
Expand Down Expand Up @@ -537,3 +537,14 @@ func (m *MapType) AllowsKey(key interface{}) bool {
}
return false
}

// AllowedKeys returns the set of keys (in string format) permitted in this map.
func (m *MapType) AllowedKeys() []string {
var keysAsString []string

for _, item := range m.Items {
keysAsString = append(keysAsString, fmt.Sprintf("%s", item.Key))
}

return keysAsString
}
116 changes: 116 additions & 0 deletions pkg/spell/spell.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
// Copyright 2021 VMware, Inc.
// SPDX-License-Identifier: Apache-2.0

// Package spell file defines a simple spelling checker for use in attribute errors
// such as "no such field .foo; did you mean .food?".
// (this source was copied from https://github.com/google/starlark-go/blob/b0039bd2cfe369fe8f2bdba0614bafd1f9402dbb/internal/spell/spell.go)
package spell

import (
"strings"
"unicode"
)

// Nearest returns the element of candidates
// nearest to x using the Levenshtein metric,
// or "" if none were promising.
func Nearest(x string, candidates []string) string {
// Ignore underscores and case when matching.
fold := func(s string) string {
return strings.Map(func(r rune) rune {
if r == '_' {
return -1
}
return unicode.ToLower(r)
}, s)
}

x = fold(x)

var best string
bestD := (len(x) + 1) / 2 // allow up to 50% typos
for _, c := range candidates {
d := levenshtein(x, fold(c), bestD)
if d < bestD {
bestD = d
best = c
}
}
return best
}

// levenshtein returns the non-negative Levenshtein edit distance
// between the byte strings x and y.
//
// If the computed distance exceeds max,
// the function may return early with an approximate value > max.
func levenshtein(x, y string, max int) int {
// This implementation is derived from one by Laurent Le Brun in
// Bazel that uses the single-row space efficiency trick
// described at bitbucket.org/clearer/iosifovich.

// Let x be the shorter string.
if len(x) > len(y) {
x, y = y, x
}

// Remove common prefix.
for i := 0; i < len(x); i++ {
if x[i] != y[i] {
x = x[i:]
y = y[i:]
break
}
}
if x == "" {
return len(y)
}

if d := abs(len(x) - len(y)); d > max {
return d // excessive length divergence
}

row := make([]int, len(y)+1)
for i := range row {
row[i] = i
}

for i := 1; i <= len(x); i++ {
row[0] = i
best := i
prev := i - 1
for j := 1; j <= len(y); j++ {
a := prev + b2i(x[i-1] != y[j-1]) // substitution
b := 1 + row[j-1] // deletion
c := 1 + row[j] // insertion
k := min(a, min(b, c))
prev, row[j] = row[j], k
best = min(best, k)
}
if best > max {
return best
}
}
return row[len(y)]
}

func b2i(b bool) int {
if b {
return 1
}
return 0
}

func min(x, y int) int {
if x < y {
return x
}
return y
}

func abs(x int) int {
if x >= 0 {
return x
}
return -x
}

0 comments on commit c8ed5cd

Please sign in to comment.