Add semantic paths and safe diagnostics
This commit is contained in:
239
diagnostic.go
Normal file
239
diagnostic.go
Normal file
@@ -0,0 +1,239 @@
|
||||
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 = "<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()
|
||||
}
|
||||
Reference in New Issue
Block a user