262 lines
6.4 KiB
Go
262 lines
6.4 KiB
Go
package engine
|
|
|
|
import (
|
|
"cmp"
|
|
"fmt"
|
|
"io"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"unicode"
|
|
)
|
|
|
|
// LoadError reports safe structured diagnostics for a failed configuration load.
|
|
// It intentionally does not expose or unwrap parser and application errors.
|
|
type LoadError struct {
|
|
diagnostics []Diagnostic
|
|
sources []SourceInfo
|
|
}
|
|
|
|
// Error implements error without rendering diagnostic messages or input values.
|
|
func (e *LoadError) Error() string {
|
|
if e == nil {
|
|
return "configkit: configuration load failed"
|
|
}
|
|
count := 0
|
|
for _, diagnostic := range e.diagnostics {
|
|
if diagnostic.Severity == SeverityError {
|
|
count++
|
|
}
|
|
}
|
|
if count == 1 {
|
|
return "configkit: configuration load failed with 1 error"
|
|
}
|
|
return fmt.Sprintf("configkit: configuration load failed with %d errors", count)
|
|
}
|
|
|
|
// Diagnostics returns a deep copy of the error's canonical diagnostics.
|
|
func (e *LoadError) Diagnostics() []Diagnostic {
|
|
if e == nil {
|
|
return nil
|
|
}
|
|
return cloneDiagnostics(e.diagnostics)
|
|
}
|
|
|
|
// Sources returns a deep copy of sources discovered before loading failed.
|
|
func (e *LoadError) Sources() []SourceInfo {
|
|
if e == nil {
|
|
return nil
|
|
}
|
|
return cloneSourceInfos(e.sources)
|
|
}
|
|
|
|
// WriteDiagnostics writes diagnostics in canonical order. It escapes terminal
|
|
// control characters and propagates writer errors, including short writes.
|
|
func WriteDiagnostics(output io.Writer, diagnostics []Diagnostic) error {
|
|
canonical := canonicalDiagnostics(diagnostics)
|
|
for _, diagnostic := range canonical {
|
|
if err := writeDiagnostic(output, diagnostic); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type diagnosticAccumulator struct {
|
|
diagnostics []Diagnostic
|
|
sources []SourceInfo
|
|
}
|
|
|
|
func (a *diagnosticAccumulator) add(diagnostics ...Diagnostic) {
|
|
for _, diagnostic := range diagnostics {
|
|
a.diagnostics = append(a.diagnostics, cloneDiagnostic(diagnostic))
|
|
}
|
|
}
|
|
|
|
func (a *diagnosticAccumulator) loadError() *LoadError {
|
|
return &LoadError{
|
|
diagnostics: canonicalDiagnostics(a.diagnostics),
|
|
sources: cloneSourceInfos(a.sources),
|
|
}
|
|
}
|
|
|
|
func canonicalDiagnostics(diagnostics []Diagnostic) []Diagnostic {
|
|
canonical := cloneDiagnostics(diagnostics)
|
|
for i := range canonical {
|
|
slices.SortFunc(canonical[i].Related, compareLocation)
|
|
}
|
|
type orderedDiagnostic struct {
|
|
diagnostic Diagnostic
|
|
pointer string
|
|
}
|
|
ordered := make([]orderedDiagnostic, len(canonical))
|
|
for i, diagnostic := range canonical {
|
|
ordered[i] = orderedDiagnostic{diagnostic: diagnostic, pointer: diagnostic.Path.String()}
|
|
}
|
|
slices.SortFunc(ordered, func(left, right orderedDiagnostic) int {
|
|
return compareDiagnosticWithPaths(left.diagnostic, right.diagnostic, left.pointer, right.pointer)
|
|
})
|
|
for i, item := range ordered {
|
|
canonical[i] = item.diagnostic
|
|
}
|
|
return canonical
|
|
}
|
|
|
|
func compareDiagnosticWithPaths(left, right Diagnostic, leftPath, rightPath string) int {
|
|
if order := compareOptionalLocation(left.Primary, right.Primary); order != 0 {
|
|
return order
|
|
}
|
|
if order := cmp.Compare(leftPath, rightPath); order != 0 {
|
|
return order
|
|
}
|
|
if order := cmp.Compare(left.Code, right.Code); order != 0 {
|
|
return order
|
|
}
|
|
if order := cmp.Compare(left.Severity, right.Severity); order != 0 {
|
|
return order
|
|
}
|
|
if order := cmp.Compare(left.Message, right.Message); order != 0 {
|
|
return order
|
|
}
|
|
return compareLocations(left.Related, right.Related)
|
|
}
|
|
|
|
func compareOptionalLocation(left, right *Location) int {
|
|
if left == nil && right == nil {
|
|
return 0
|
|
}
|
|
if left == nil {
|
|
return -1
|
|
}
|
|
if right == nil {
|
|
return 1
|
|
}
|
|
return compareLocation(*left, *right)
|
|
}
|
|
|
|
func compareLocations(left, right []Location) int {
|
|
for i := 0; i < min(len(left), len(right)); i++ {
|
|
if order := compareLocation(left[i], right[i]); order != 0 {
|
|
return order
|
|
}
|
|
}
|
|
return cmp.Compare(len(left), len(right))
|
|
}
|
|
|
|
func compareLocation(left, right Location) int {
|
|
if order := compareSourceID(left.SourceID, right.SourceID); order != 0 {
|
|
return order
|
|
}
|
|
if order := cmp.Compare(left.Line, right.Line); order != 0 {
|
|
return order
|
|
}
|
|
return cmp.Compare(left.Column, right.Column)
|
|
}
|
|
|
|
func compareSourceID(left, right string) int {
|
|
leftOrder, leftOK := sourceSequence(left)
|
|
rightOrder, rightOK := sourceSequence(right)
|
|
if leftOK && rightOK {
|
|
return cmp.Compare(leftOrder, rightOrder)
|
|
}
|
|
return cmp.Compare(left, right)
|
|
}
|
|
|
|
func sourceSequence(sourceID string) (int, bool) {
|
|
const prefix = "source-"
|
|
if !strings.HasPrefix(sourceID, prefix) {
|
|
return 0, false
|
|
}
|
|
value, err := strconv.Atoi(strings.TrimPrefix(sourceID, prefix))
|
|
if err != nil || value < 1 {
|
|
return 0, false
|
|
}
|
|
return value, true
|
|
}
|
|
|
|
func writeDiagnostic(output io.Writer, diagnostic Diagnostic) error {
|
|
severity := diagnostic.Severity
|
|
if severity == "" {
|
|
severity = SeverityError
|
|
}
|
|
path := diagnostic.Path.String()
|
|
if path == "" {
|
|
path = "<root>"
|
|
}
|
|
|
|
var b strings.Builder
|
|
b.WriteString(escapeControls(string(severity)))
|
|
if diagnostic.Code != "" {
|
|
b.WriteString(" [")
|
|
b.WriteString(escapeControls(string(diagnostic.Code)))
|
|
b.WriteByte(']')
|
|
}
|
|
b.WriteByte(' ')
|
|
b.WriteString(escapeControls(path))
|
|
if diagnostic.Primary != nil {
|
|
b.WriteString(" (")
|
|
writeLocationText(&b, *diagnostic.Primary)
|
|
b.WriteByte(')')
|
|
}
|
|
if diagnostic.Message != "" {
|
|
b.WriteString(": ")
|
|
b.WriteString(escapeControls(diagnostic.Message))
|
|
}
|
|
b.WriteByte('\n')
|
|
for _, related := range diagnostic.Related {
|
|
b.WriteString(" related: ")
|
|
writeLocationText(&b, related)
|
|
b.WriteByte('\n')
|
|
}
|
|
return writeString(output, b.String())
|
|
}
|
|
|
|
func writeLocationText(output *strings.Builder, location Location) {
|
|
label := location.SourceID
|
|
if label == "" {
|
|
label = "<synthetic>"
|
|
}
|
|
output.WriteString(escapeControls(label))
|
|
if location.Line > 0 {
|
|
output.WriteByte(':')
|
|
output.WriteString(strconv.Itoa(location.Line))
|
|
if location.Column > 0 {
|
|
output.WriteByte(':')
|
|
output.WriteString(strconv.Itoa(location.Column))
|
|
}
|
|
}
|
|
}
|
|
|
|
func writeString(output io.Writer, value string) error {
|
|
written, err := io.WriteString(output, value)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if written != len(value) {
|
|
return io.ErrShortWrite
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func escapeControls(value string) string {
|
|
var b strings.Builder
|
|
for _, r := range value {
|
|
if unicode.IsControl(r) {
|
|
quoted := strconv.QuoteRune(r)
|
|
b.WriteString(quoted[1 : len(quoted)-1])
|
|
continue
|
|
}
|
|
b.WriteRune(r)
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
func hasErrors(diagnostics []Diagnostic) bool {
|
|
for _, diagnostic := range diagnostics {
|
|
if diagnostic.Severity == SeverityError {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|