Skip to content

Commit c443008

Browse files
authored
Merge pull request #300 from Arpit529Srivastava/issue2043-vet-cmd
feat: add structured json output support for kcl vet command
2 parents 3a679ab + 48b6c0b commit c443008

File tree

1 file changed

+201
-20
lines changed

1 file changed

+201
-20
lines changed

cmd/kcl/commands/vet.go

Lines changed: 201 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,16 @@
33
package cmd
44

55
import (
6+
"encoding/json"
67
"errors"
78
"fmt"
89
"io"
910
"os"
11+
"regexp"
12+
"strconv"
13+
"strings"
1014

15+
"github.com/acarl005/stripansi"
1116
"github.com/spf13/cobra"
1217
"kcl-lang.io/cli/pkg/fs"
1318
"kcl-lang.io/kcl-go/pkg/kcl"
@@ -25,12 +30,49 @@ const (
2530
kcl vet data.yaml code.k --format yaml
2631
2732
# Validate the JSON data using the kcl code with the schema name
28-
kcl vet data.json code.k -s Schema`
33+
kcl vet data.json code.k -s Schema
34+
35+
# Validate and output results as JSON for CI/CD integration
36+
kcl vet data.json code.k --output json`
2937
)
3038

39+
// VetOptions holds the options for the vet command.
40+
type VetOptions struct {
41+
validate.ValidateOptions
42+
// Output specifies the output format: "text" (default) or "json"
43+
Output string
44+
}
45+
46+
// VetResult represents a structured validation result for JSON output.
47+
type VetResult struct {
48+
Success bool `json:"success"`
49+
ErrCount int `json:"errCount,omitempty"`
50+
Errors []VetError `json:"errors,omitempty"`
51+
Message string `json:"message,omitempty"`
52+
}
53+
54+
// VetError represents a single validation error in structured format.
55+
type VetError struct {
56+
ErrorType string `json:"errorType,omitempty"`
57+
File string `json:"file,omitempty"`
58+
Line int `json:"line,omitempty"`
59+
Column int `json:"column,omitempty"`
60+
Message string `json:"message,omitempty"`
61+
CodeSnippet string `json:"codeSnippet,omitempty"`
62+
Schema *SchemaError `json:"schema,omitempty"`
63+
}
64+
65+
// SchemaError represents schema-related error details.
66+
type SchemaError struct {
67+
Filepath string `json:"filepath,omitempty"`
68+
Line int `json:"line,omitempty"`
69+
Column int `json:"column,omitempty"`
70+
Details string `json:"details,omitempty"`
71+
}
72+
3173
// NewVetCmd returns the vet command.
3274
func NewVetCmd() *cobra.Command {
33-
o := validate.ValidateOptions{}
75+
o := VetOptions{}
3476
cmd := &cobra.Command{
3577
Use: "vet",
3678
Short: "KCL validation tool",
@@ -52,46 +94,49 @@ func NewVetCmd() *cobra.Command {
5294
"Specify the validate config attribute name.")
5395
cmd.Flags().StringVar(&o.Format, "format", "",
5496
"Specify the validate data format. e.g., yaml, json. Default is json")
97+
cmd.Flags().StringVar(&o.Output, "output", "text",
98+
"Specify the output format. e.g., text, json. Default is text")
5599

56100
return cmd
57101
}
58102

59-
func doValidate(dataFile, codeFile string, o *validate.ValidateOptions) error {
103+
func doValidate(dataFile, codeFile string, o *VetOptions) error {
60104
var ok bool
105+
var errMsg string
61106
if dataFile == "-" {
62107
// Read data from stdin
63108
input, err := io.ReadAll(os.Stdin)
64109
if err != nil {
65-
return err
110+
return outputResult(o.Output, false, "", err)
66111
}
67112
code, err := os.ReadFile(codeFile)
68113
if err != nil {
69-
return err
114+
return outputResult(o.Output, false, "", err)
70115
}
71-
ok, err = validate.ValidateCode(string(input), string(code), o)
116+
ok, err = validate.ValidateCode(string(input), string(code), &o.ValidateOptions)
72117
if err != nil {
73-
return err
118+
return outputResult(o.Output, false, err.Error(), nil)
74119
}
75120
} else {
76121
// Read data from files
77122
dataFiles, err := fs.ExpandInputFiles([]string{dataFile}, false)
78123
if err != nil {
79-
return err
124+
return outputResult(o.Output, false, "", err)
80125
}
81126
for _, dataFile := range dataFiles {
82-
ok, err = validateFile(dataFile, codeFile, o)
127+
ok, errMsg, err = validateFile(dataFile, codeFile, &o.ValidateOptions)
83128
if err != nil {
84-
return err
129+
return outputResult(o.Output, false, "", err)
130+
}
131+
if !ok {
132+
return outputResult(o.Output, false, errMsg, nil)
85133
}
86134
}
87135
}
88-
if ok {
89-
fmt.Println("Validate success!")
90-
}
91-
return nil
136+
return outputResult(o.Output, ok, "", nil)
92137
}
93138

94-
func validateFile(dataFile, codeFile string, opts *validate.ValidateOptions) (ok bool, err error) {
139+
func validateFile(dataFile, codeFile string, opts *validate.ValidateOptions) (ok bool, errMsg string, err error) {
95140
if opts == nil {
96141
opts = &validate.ValidateOptions{}
97142
}
@@ -104,11 +149,147 @@ func validateFile(dataFile, codeFile string, opts *validate.ValidateOptions) (ok
104149
Format: opts.Format,
105150
})
106151
if err != nil {
107-
return false, err
152+
return false, "", err
153+
}
154+
return resp.Success, resp.ErrMessage, nil
155+
}
156+
157+
// outputResult outputs the validation result in the specified format.
158+
func outputResult(outputFormat string, success bool, errMsg string, err error) error {
159+
if strings.ToLower(outputFormat) == "json" {
160+
return outputJSON(success, errMsg, err)
161+
}
162+
// Default text output
163+
return outputText(success, errMsg, err)
164+
}
165+
166+
// outputText outputs the validation result in text format (original behavior).
167+
func outputText(success bool, errMsg string, err error) error {
168+
if err != nil {
169+
return err
170+
}
171+
if errMsg != "" {
172+
return errors.New(errMsg)
108173
}
109-
var e error = nil
110-
if resp.ErrMessage != "" {
111-
e = errors.New(resp.ErrMessage)
174+
if success {
175+
fmt.Println("Validate success!")
112176
}
113-
return resp.Success, e
177+
return nil
178+
}
179+
180+
// outputJSON outputs the validation result in JSON format.
181+
func outputJSON(success bool, errMsg string, err error) error {
182+
result := VetResult{
183+
Success: success,
184+
}
185+
186+
if err != nil {
187+
result.Errors = []VetError{{
188+
ErrorType: "Error",
189+
Message: stripansi.Strip(err.Error()),
190+
}}
191+
result.ErrCount = 1
192+
} else if errMsg != "" {
193+
// Strip ANSI codes from the error message before parsing
194+
cleanErrMsg := stripansi.Strip(errMsg)
195+
result.Errors = parseErrorMessage(cleanErrMsg)
196+
result.ErrCount = len(result.Errors)
197+
} else if success {
198+
result.Message = "Validate success!"
199+
}
200+
201+
jsonOutput, jsonErr := json.MarshalIndent(result, "", " ")
202+
if jsonErr != nil {
203+
return jsonErr
204+
}
205+
fmt.Println(string(jsonOutput))
206+
return nil
207+
}
208+
209+
// parseErrorMessage attempts to parse the error message into structured errors.
210+
// KCL error format example:
211+
//
212+
// EvaluationError
213+
// --> path/to/file.yaml:3:9
214+
// |
215+
// 3 | app_name: "test"
216+
// | ^ Instance check failed
217+
func parseErrorMessage(errMsg string) []VetError {
218+
var vetErrors []VetError
219+
220+
// Pattern to match error location: --> filepath:line:column
221+
locationPattern := regexp.MustCompile(`-->\s*([^:]+):(\d+):(\d+)`)
222+
// Pattern to match error type at the start
223+
// Pattern to match error type at the start
224+
errorTypePattern := regexp.MustCompile(`^(\w+Error|\w*Exception)`)
225+
// Pattern to match the error message after ^
226+
messagePattern := regexp.MustCompile(`\^\s*(.+)$`)
227+
// Pattern to match code snippet (line number | code)
228+
snippetPattern := regexp.MustCompile(`^\s*\d+\s*\|\s*(.+)$`)
229+
230+
lines := strings.Split(errMsg, "\n")
231+
232+
var currentError *VetError
233+
for _, line := range lines {
234+
line = strings.TrimSpace(line)
235+
if line == "" || line == "|" {
236+
continue
237+
}
238+
239+
// Check for error type
240+
if matches := errorTypePattern.FindStringSubmatch(line); matches != nil {
241+
if currentError != nil {
242+
vetErrors = append(vetErrors, *currentError)
243+
}
244+
currentError = &VetError{
245+
ErrorType: matches[1],
246+
}
247+
continue
248+
}
249+
250+
// Check for location
251+
if matches := locationPattern.FindStringSubmatch(line); matches != nil {
252+
if currentError == nil {
253+
currentError = &VetError{}
254+
}
255+
currentError.File = matches[1]
256+
if lineNum, err := strconv.Atoi(matches[2]); err == nil {
257+
currentError.Line = lineNum
258+
}
259+
if colNum, err := strconv.Atoi(matches[3]); err == nil {
260+
currentError.Column = colNum
261+
}
262+
continue
263+
}
264+
265+
// Check for error message (contains ^)
266+
if matches := messagePattern.FindStringSubmatch(line); matches != nil {
267+
if currentError != nil {
268+
currentError.Message = strings.TrimSpace(matches[1])
269+
}
270+
continue
271+
}
272+
273+
// Check for code snippet
274+
if matches := snippetPattern.FindStringSubmatch(line); matches != nil {
275+
if currentError != nil && currentError.CodeSnippet == "" {
276+
currentError.CodeSnippet = strings.TrimSpace(matches[1])
277+
}
278+
continue
279+
}
280+
}
281+
282+
// Don't forget the last error
283+
if currentError != nil {
284+
vetErrors = append(vetErrors, *currentError)
285+
}
286+
287+
// If parsing failed, return the raw message as a single error
288+
if len(vetErrors) == 0 {
289+
vetErrors = append(vetErrors, VetError{
290+
Message: errMsg,
291+
})
292+
}
293+
294+
return vetErrors
114295
}

0 commit comments

Comments
 (0)