package configkit 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) } slices.SortFunc(canonical, compareDiagnostic) return canonical } func compareDiagnostic(left, right Diagnostic) int { if order := compareOptionalLocation(left.Primary, right.Primary); order != 0 { return order } if order := cmp.Compare(left.Path.String(), right.Path.String()); 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 = "" } 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 = "" } 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() }