Skip to content

Commit b69e666

Browse files
committed
feat: add line/column positions to JSON and YAML schema errors
Schema validation errors now include source positions, pointing to the exact line and column of the offending key in the original file. For JSON, a position map is built by tokenizing the source with encoding/json.Decoder and tracking byte offsets for each key path. For YAML, the yaml.v3 Node API provides line/column on every node. The gojsonschema library reports errors with JSON paths like "(root).server.port". These paths are looked up in the position map to annotate each error with its source location. Changes: - SchemaErrors gains a Positions field (parallel to Items) - New SourcePosition type and JSONSchemaValidateWithPositions function - buildJSONPositionMap: tokenizes JSON and maps context paths to positions - buildYAMLPositionMap/walkYAMLNode: walks yaml.v3 Node tree for positions - Report gains ErrorLines/ErrorColumns (parallel to ValidationErrors) - SARIF reporter uses per-error positions instead of file-level position - formatErrors formats schema errors with consistent line/column prefix Output before: error: schema: version: Invalid type. Expected: string, given: integer Output after: error: schema: line 3, column 3: version: Invalid type. Expected: string, given: integer
1 parent 943a573 commit b69e666

File tree

9 files changed

+375
-16
lines changed

9 files changed

+375
-16
lines changed

pkg/cli/cli.go

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,7 @@ func (c *CLI) validate(content []byte, ft filetype.FileType, name, path string)
164164
col = ve.Column
165165
}
166166

167-
validationErrors := formatErrors(err, line, col)
167+
validationErrors, errLines, errCols := formatErrors(err, line, col)
168168
notes := checkJSONCFallback(syntaxErr, ft, content, name)
169169

170170
return reporter.Report{
@@ -178,6 +178,8 @@ func (c *CLI) validate(content []byte, ft filetype.FileType, name, path string)
178178
IsQuiet: c.quiet,
179179
StartLine: line,
180180
StartColumn: col,
181+
ErrorLines: errLines,
182+
ErrorColumns: errCols,
181183
}
182184
}
183185

@@ -195,17 +197,33 @@ func (c *CLI) runSingle(content []byte, ft filetype.FileType, name string) (int,
195197
return 0, nil
196198
}
197199

198-
func formatErrors(err error, line, col int) []string {
200+
func formatErrors(err error, line, col int) (errs []string, lines []int, cols []int) {
199201
if err == nil {
200-
return nil
202+
return nil, nil, nil
201203
}
202204
var se *validator.SchemaErrors
203205
if errors.As(err, &se) {
204206
var errs []string
205-
for _, e := range se.Errors() {
206-
errs = append(errs, "schema: "+e)
207+
var lines, cols []int
208+
for i, e := range se.Errors() {
209+
var pos validator.SchemaErrorPosition
210+
if i < len(se.Positions) {
211+
pos = se.Positions[i]
212+
}
213+
var prefix string
214+
switch {
215+
case pos.Line > 0 && pos.Column > 0:
216+
prefix = fmt.Sprintf("schema: line %d, column %d: ", pos.Line, pos.Column)
217+
case pos.Line > 0:
218+
prefix = fmt.Sprintf("schema: line %d: ", pos.Line)
219+
default:
220+
prefix = "schema: "
221+
}
222+
errs = append(errs, prefix+e)
223+
lines = append(lines, pos.Line)
224+
cols = append(cols, pos.Column)
207225
}
208-
return errs
226+
return errs, lines, cols
209227
}
210228

211229
msg := err.Error()
@@ -224,7 +242,7 @@ func formatErrors(err error, line, col int) []string {
224242
prefix = "syntax: "
225243
}
226244

227-
return []string{prefix + msg}
245+
return []string{prefix + msg}, []int{line}, []int{col}
228246
}
229247

230248
// checkJSONCFallback checks if a failed JSON file is valid JSONC and returns a note if so.

pkg/cli/cli_test.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -715,6 +715,50 @@ func Test_SchemaErrorsMethod(t *testing.T) {
715715
require.Equal(t, "test: error1; error2", se.Error())
716716
}
717717

718+
func Test_formatErrorsSchemaWithPositions(t *testing.T) {
719+
t.Parallel()
720+
se := &validator.SchemaErrors{
721+
Prefix: "schema validation failed: ",
722+
Items: []string{"port: Invalid type", "name is required"},
723+
Positions: []validator.SchemaErrorPosition{
724+
{Line: 3, Column: 5},
725+
{Line: 0, Column: 0},
726+
},
727+
}
728+
errs, lines, cols := formatErrors(se, 0, 0)
729+
require.Len(t, errs, 2)
730+
require.Contains(t, errs[0], "line 3, column 5")
731+
require.Contains(t, errs[1], "schema: ")
732+
require.NotContains(t, errs[1], "line")
733+
require.Equal(t, 3, lines[0])
734+
require.Equal(t, 0, lines[1])
735+
require.Equal(t, 5, cols[0])
736+
}
737+
738+
func Test_formatErrorsSchemaLineOnly(t *testing.T) {
739+
t.Parallel()
740+
se := &validator.SchemaErrors{
741+
Prefix: "schema validation failed: ",
742+
Items: []string{"port: Invalid type"},
743+
Positions: []validator.SchemaErrorPosition{
744+
{Line: 7, Column: 0},
745+
},
746+
}
747+
errs, lines, _ := formatErrors(se, 0, 0)
748+
require.Len(t, errs, 1)
749+
require.Contains(t, errs[0], "line 7:")
750+
require.NotContains(t, errs[0], "column")
751+
require.Equal(t, 7, lines[0])
752+
}
753+
754+
func Test_formatErrorsNil(t *testing.T) {
755+
t.Parallel()
756+
errs, lines, cols := formatErrors(nil, 0, 0)
757+
require.Nil(t, errs)
758+
require.Nil(t, lines)
759+
require.Nil(t, cols)
760+
}
761+
718762
func Test_CLINoJSONCNoteOnYAML(t *testing.T) {
719763
dir := t.TempDir()
720764
testhelper.WriteFile(t, dir, "bad.yaml", "a: b\nc: d:::::::::::::::\n")

pkg/reporter/reporter.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ type Report struct {
1313
IsQuiet bool
1414
StartLine int
1515
StartColumn int
16+
ErrorLines []int
17+
ErrorColumns []int
1618
}
1719

1820
// Reporter is the interface that wraps the Print method

pkg/reporter/sarif_reporter.go

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ func createSARIFReport(reports []Report) (*SARIFLog, error) {
106106
continue
107107
}
108108

109-
for _, errMsg := range report.ValidationErrors {
109+
for i, errMsg := range report.ValidationErrors {
110110
r := result{
111111
Kind: "fail",
112112
Level: "error",
@@ -117,10 +117,17 @@ func createSARIFReport(reports []Report) (*SARIFLog, error) {
117117
},
118118
}},
119119
}
120-
if report.StartLine > 0 {
120+
errLine, errCol := report.StartLine, report.StartColumn
121+
if i < len(report.ErrorLines) && report.ErrorLines[i] > 0 {
122+
errLine = report.ErrorLines[i]
123+
if i < len(report.ErrorColumns) {
124+
errCol = report.ErrorColumns[i]
125+
}
126+
}
127+
if errLine > 0 {
121128
r.Locations[0].PhysicalLocation.Region = &region{
122-
StartLine: report.StartLine,
123-
StartColumn: report.StartColumn,
129+
StartLine: errLine,
130+
StartColumn: errCol,
124131
}
125132
}
126133
runs.Results = append(runs.Results, r)

pkg/validator/json.go

Lines changed: 103 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,8 @@ func (JSONValidator) ValidateSchema(b []byte, filePath string) (bool, error) {
8686
return false, err
8787
}
8888

89-
return JSONSchemaValidate(schemaURL, cleanDoc)
89+
posMap := buildJSONPositionMap(b)
90+
return JSONSchemaValidateWithPositions(schemaURL, cleanDoc, posMap)
9091
}
9192

9293
// checkJSONDuplicateKeys walks the JSON token stream and reports duplicate keys.
@@ -95,6 +96,107 @@ func checkJSONDuplicateKeys(b []byte) error {
9596
return checkDuplicateKeysInDecoder(dec)
9697
}
9798

99+
// buildJSONPositionMap scans JSON bytes and builds a map from gojsonschema
100+
// context paths (e.g. "(root).name") to their source position.
101+
func buildJSONPositionMap(b []byte) map[string]SourcePosition {
102+
positions := make(map[string]SourcePosition)
103+
dec := json.NewDecoder(strings.NewReader(string(b)))
104+
105+
offsetToPos := func(offset int64) SourcePosition {
106+
if int(offset) > len(b) {
107+
offset = int64(len(b))
108+
}
109+
prefix := b[:offset]
110+
line := 1 + strings.Count(string(prefix), "\n")
111+
lastNL := strings.LastIndex(string(prefix), "\n")
112+
col := int(offset) - lastNL
113+
return SourcePosition{Line: line, Column: col}
114+
}
115+
116+
findKeyStart := func(endOffset int64) SourcePosition {
117+
for i := int(endOffset) - 1; i >= 0; i-- {
118+
if b[i] == '"' {
119+
for j := i - 1; j >= 0; j-- {
120+
if b[j] == '"' {
121+
return offsetToPos(int64(j))
122+
}
123+
}
124+
}
125+
}
126+
return offsetToPos(endOffset)
127+
}
128+
129+
type frame struct {
130+
isArray bool
131+
key string
132+
}
133+
134+
var stack []frame
135+
var pendingKey string
136+
expectingKey := false
137+
138+
pathStr := func() string {
139+
parts := []string{"(root)"}
140+
for _, f := range stack {
141+
if f.key != "" {
142+
parts = append(parts, f.key)
143+
}
144+
}
145+
if pendingKey != "" {
146+
parts = append(parts, pendingKey)
147+
}
148+
return strings.Join(parts, ".")
149+
}
150+
151+
recordAndReset := func() {
152+
pendingKey = ""
153+
expectingKey = len(stack) > 0 && !stack[len(stack)-1].isArray
154+
}
155+
156+
for {
157+
tok, err := dec.Token()
158+
if err != nil {
159+
break
160+
}
161+
afterOffset := dec.InputOffset()
162+
163+
switch v := tok.(type) {
164+
case json.Delim:
165+
switch v {
166+
case '{':
167+
stack = append(stack, frame{isArray: false, key: pendingKey})
168+
pendingKey = ""
169+
expectingKey = true
170+
p := pathStr()
171+
if _, exists := positions[p]; !exists {
172+
positions[p] = offsetToPos(afterOffset - 1)
173+
}
174+
case '[':
175+
stack = append(stack, frame{isArray: true, key: pendingKey})
176+
pendingKey = ""
177+
case '}', ']':
178+
if len(stack) > 0 {
179+
stack = stack[:len(stack)-1]
180+
}
181+
expectingKey = len(stack) > 0 && !stack[len(stack)-1].isArray
182+
default:
183+
}
184+
case string:
185+
if expectingKey && len(stack) > 0 && !stack[len(stack)-1].isArray {
186+
pendingKey = v
187+
positions[pathStr()] = findKeyStart(afterOffset)
188+
expectingKey = false
189+
} else {
190+
recordAndReset()
191+
}
192+
default:
193+
recordAndReset()
194+
}
195+
}
196+
197+
return positions
198+
}
199+
98200
func checkDuplicateKeysInDecoder(dec *json.Decoder) error {
99201
tok, err := dec.Token()
100202
if err != nil {

pkg/validator/schema.go

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,24 @@ import (
88
"github.com/xeipuuv/gojsonschema"
99
)
1010

11+
// SourcePosition holds a 1-based line and column in the original source file.
12+
type SourcePosition struct {
13+
Line int
14+
Column int
15+
}
16+
1117
func JSONSchemaValidate(schemaURL string, docJSON []byte) (bool, error) {
18+
return validateJSONSchema(schemaURL, docJSON, nil)
19+
}
20+
21+
// JSONSchemaValidateWithPositions validates docJSON against schemaURL and
22+
// annotates errors with source positions from posMap. The map keys are
23+
// gojsonschema context strings like "(root).name".
24+
func JSONSchemaValidateWithPositions(schemaURL string, docJSON []byte, posMap map[string]SourcePosition) (bool, error) {
25+
return validateJSONSchema(schemaURL, docJSON, posMap)
26+
}
27+
28+
func validateJSONSchema(schemaURL string, docJSON []byte, posMap map[string]SourcePosition) (bool, error) {
1229
schemaLoader := gojsonschema.NewReferenceLoader(schemaURL)
1330
documentLoader := gojsonschema.NewBytesLoader(docJSON)
1431

@@ -19,10 +36,18 @@ func JSONSchemaValidate(schemaURL string, docJSON []byte) (bool, error) {
1936

2037
if !result.Valid() {
2138
var errs []string
39+
var positions []SchemaErrorPosition
2240
for _, desc := range result.Errors() {
2341
errs = append(errs, desc.String())
42+
var pos SchemaErrorPosition
43+
if posMap != nil {
44+
if sp, ok := posMap[desc.Context().String()]; ok {
45+
pos = SchemaErrorPosition(sp)
46+
}
47+
}
48+
positions = append(positions, pos)
2449
}
25-
return false, &SchemaErrors{Prefix: "schema validation failed: ", Items: errs}
50+
return false, &SchemaErrors{Prefix: "schema validation failed: ", Items: errs, Positions: positions}
2651
}
2752

2853
return true, nil

pkg/validator/validator.go

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,21 @@ type ValidationError struct {
2020
func (e *ValidationError) Error() string { return e.Err.Error() }
2121
func (e *ValidationError) Unwrap() error { return e.Err }
2222

23+
// SchemaErrorPosition holds the source position for a single schema error.
24+
type SchemaErrorPosition struct {
25+
Line int
26+
Column int
27+
}
28+
2329
// SchemaErrors holds multiple schema validation errors.
2430
// The joined Error() string is used for backward compatibility,
2531
// while Errors() returns individual error messages.
32+
// Positions is parallel to Items — Positions[i] is the source position
33+
// for Items[i]. A zero-value position means the location is unknown.
2634
type SchemaErrors struct {
27-
Prefix string
28-
Items []string
35+
Prefix string
36+
Items []string
37+
Positions []SchemaErrorPosition
2938
}
3039

3140
func (e *SchemaErrors) Error() string {

0 commit comments

Comments
 (0)