360 lines
12 KiB
Go
360 lines
12 KiB
Go
package configkit
|
|
|
|
import (
|
|
"reflect"
|
|
"slices"
|
|
"strings"
|
|
|
|
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
|
)
|
|
|
|
const closedCallbackContextMessage = "configkit: callback context is no longer active"
|
|
|
|
type resolutionState struct {
|
|
active bool
|
|
schema *schemaDescriptor
|
|
presence map[string]inputPresence
|
|
document *document.Node
|
|
records map[string]*resolutionRecord
|
|
diagnostics []Diagnostic
|
|
}
|
|
|
|
type resolutionRecord struct {
|
|
path Path
|
|
kind ChangeKind
|
|
reasons []string
|
|
dependencies []Path
|
|
helperExpected *document.Node
|
|
}
|
|
|
|
type resolutionSnapshot struct {
|
|
change Change
|
|
before *document.Node
|
|
after *document.Node
|
|
}
|
|
|
|
type resolutionResult struct {
|
|
final *document.Node
|
|
deltas []documentDelta
|
|
snapshots []resolutionSnapshot
|
|
diagnostics []Diagnostic
|
|
}
|
|
|
|
// IsExplicit reports whether surviving input explicitly defined path.
|
|
func (r *Resolution) IsExplicit(path Path) bool {
|
|
state := r.activeState()
|
|
if !state.validPath(path) {
|
|
return false
|
|
}
|
|
_, explicit := state.presence[path.String()]
|
|
return explicit
|
|
}
|
|
|
|
// Record declares the intent for a resolver mutation. A container path covers
|
|
// mutations to its descendants.
|
|
func (r *Resolution) Record(path Path, kind ChangeKind, reason string, dependencies ...Path) {
|
|
state := r.activeState()
|
|
state.addRecord(path, kind, reason, dependencies, nil)
|
|
}
|
|
|
|
// Error reports a safe, value-free resolution error at path.
|
|
func (r *Resolution) Error(path Path, message string, related ...Path) {
|
|
r.activeState().report(SeverityError, path, message, related)
|
|
}
|
|
|
|
// Warning reports a safe, value-free resolution warning at path.
|
|
func (r *Resolution) Warning(path Path, message string, related ...Path) {
|
|
r.activeState().report(SeverityWarning, path, message, related)
|
|
}
|
|
|
|
// Default assigns and records value only when path was absent from surviving
|
|
// input. Static defaults are not considered explicit.
|
|
func Default[V any](resolution *Resolution, path Path, target *V, value V, reason string, dependencies ...Path) {
|
|
state := resolution.activeState()
|
|
descriptor, ok := state.helperDescriptor(path, target)
|
|
if !ok {
|
|
return
|
|
}
|
|
if _, explicit := state.presence[path.String()]; explicit {
|
|
return
|
|
}
|
|
*target = value
|
|
expected, ok := state.projectHelperValue(path, descriptor, reflect.ValueOf(target).Elem())
|
|
if !ok {
|
|
return
|
|
}
|
|
state.addRecord(path, ChangeDerivedDefault, reason, dependencies, expected)
|
|
}
|
|
|
|
// Normalize assigns and records value regardless of input presence.
|
|
func Normalize[V any](resolution *Resolution, path Path, target *V, value V, reason string, dependencies ...Path) {
|
|
state := resolution.activeState()
|
|
descriptor, ok := state.helperDescriptor(path, target)
|
|
if !ok {
|
|
return
|
|
}
|
|
*target = value
|
|
expected, ok := state.projectHelperValue(path, descriptor, reflect.ValueOf(target).Elem())
|
|
if !ok {
|
|
return
|
|
}
|
|
state.addRecord(path, ChangeNormalization, reason, dependencies, expected)
|
|
}
|
|
|
|
func (r *Resolution) activeState() *resolutionState {
|
|
if r == nil || r.state == nil || !r.state.active {
|
|
panic(closedCallbackContextMessage)
|
|
}
|
|
return r.state
|
|
}
|
|
|
|
func (state *resolutionState) validPath(path Path) bool {
|
|
if _, ok := resolveSchemaPath(state.schema, path); ok {
|
|
return true
|
|
}
|
|
state.addDiagnostic(CodeResolveInvalidPath, path, "resolution path is not valid for the configuration schema")
|
|
return false
|
|
}
|
|
|
|
func (state *resolutionState) helperDescriptor(path Path, target any) (*typeDescriptor, bool) {
|
|
resolved, ok := resolveSchemaPath(state.schema, path)
|
|
if !ok {
|
|
state.addDiagnostic(CodeResolveInvalidPath, path, "resolution helper path is not valid for the configuration schema")
|
|
return nil, false
|
|
}
|
|
value := reflect.ValueOf(target)
|
|
if !value.IsValid() || value.Kind() != reflect.Pointer || value.IsNil() {
|
|
state.addDiagnostic(CodeResolveInvalidTarget, path, "resolution helper target must be a non-nil pointer")
|
|
return nil, false
|
|
}
|
|
if resolved.descriptor.kind != descriptorDynamic && value.Elem().Type() != resolved.descriptor.typeOf {
|
|
state.addDiagnostic(CodeResolveInvalidTarget, path, "resolution helper target type does not match its configuration path")
|
|
return nil, false
|
|
}
|
|
return resolved.descriptor, true
|
|
}
|
|
|
|
func (state *resolutionState) projectHelperValue(path Path, descriptor *typeDescriptor, value reflect.Value) (*document.Node, bool) {
|
|
if descriptor.kind == descriptorDynamic {
|
|
wrapped := reflect.New(descriptor.typeOf).Elem()
|
|
if value.IsValid() {
|
|
wrapped.Set(value)
|
|
}
|
|
value = wrapped
|
|
}
|
|
operation := valueOperation{
|
|
schema: state.schema, limits: defaultProjectionLimits(), active: make(map[valueVisit]bool),
|
|
}
|
|
node, issue := operation.project(value, descriptor, path, 0)
|
|
if issue != nil {
|
|
state.addDiagnostic(CodeResolveInvalidValue, issue.path, "resolution helper value is not a valid configuration value")
|
|
return nil, false
|
|
}
|
|
return node, true
|
|
}
|
|
|
|
func (state *resolutionState) addRecord(path Path, kind ChangeKind, reason string, dependencies []Path, expected *document.Node) {
|
|
if !state.validPath(path) {
|
|
return
|
|
}
|
|
if kind != ChangeDerivedDefault && kind != ChangeNormalization {
|
|
state.addDiagnostic(CodeResolveInvalidKind, path, "resolution record has an invalid change kind")
|
|
return
|
|
}
|
|
if reason == "" {
|
|
state.addDiagnostic(CodeResolveInvalidRecord, path, "resolution record requires a safe reason")
|
|
return
|
|
}
|
|
for _, dependency := range dependencies {
|
|
if !state.validPath(dependency) {
|
|
return
|
|
}
|
|
}
|
|
pointer := path.String()
|
|
record := state.records[pointer]
|
|
if record == nil {
|
|
record = &resolutionRecord{path: path.clone(), kind: kind}
|
|
state.records[pointer] = record
|
|
}
|
|
if kind == ChangeNormalization {
|
|
record.kind = ChangeNormalization
|
|
}
|
|
record.reasons = appendUniqueString(record.reasons, reason)
|
|
for _, dependency := range dependencies {
|
|
record.dependencies = appendUniquePath(record.dependencies, dependency)
|
|
}
|
|
if expected != nil {
|
|
record.helperExpected = document.Clone(expected)
|
|
}
|
|
}
|
|
|
|
func (state *resolutionState) report(severity Severity, path Path, message string, related []Path) {
|
|
code := CodeResolveError
|
|
if severity == SeverityWarning {
|
|
code = CodeResolveWarning
|
|
}
|
|
if !state.validPath(path) {
|
|
return
|
|
}
|
|
diagnostic := Diagnostic{Code: code, Severity: severity, Path: path.clone(), Message: message}
|
|
if location, ok := nearestDocumentLocation(state.document, path); ok {
|
|
diagnostic.Primary = documentLocationPointer(location)
|
|
}
|
|
for _, relatedPath := range related {
|
|
if !state.validPath(relatedPath) {
|
|
continue
|
|
}
|
|
if location, ok := nearestDocumentLocation(state.document, relatedPath); ok {
|
|
diagnostic.Related = append(diagnostic.Related, Location{
|
|
SourceID: location.SourceID, Line: location.Line, Column: location.Column,
|
|
})
|
|
}
|
|
}
|
|
state.diagnostics = append(state.diagnostics, diagnostic)
|
|
}
|
|
|
|
func (state *resolutionState) addDiagnostic(code Code, path Path, message string) {
|
|
diagnostic := Diagnostic{Code: code, Severity: SeverityError, Path: path.clone(), Message: message}
|
|
if location, ok := nearestDocumentLocation(state.document, path); ok {
|
|
diagnostic.Primary = documentLocationPointer(location)
|
|
}
|
|
state.diagnostics = append(state.diagnostics, diagnostic)
|
|
}
|
|
|
|
func runResolution[T any](configuration *T, resolver func(*T, *Resolution) error, schema *schemaDescriptor, decoded decodedConfiguration, input *document.Node) resolutionResult {
|
|
state := &resolutionState{
|
|
active: true, schema: schema, presence: decoded.presence, document: input,
|
|
records: make(map[string]*resolutionRecord),
|
|
}
|
|
context := &Resolution{state: state}
|
|
err := resolver(configuration, context)
|
|
state.active = false
|
|
if err != nil && !hasErrors(state.diagnostics) {
|
|
state.addDiagnostic(CodeResolveHookFailed, Path{}, "application resolution failed")
|
|
}
|
|
if hasErrors(state.diagnostics) {
|
|
return resolutionResult{diagnostics: canonicalDiagnostics(state.diagnostics)}
|
|
}
|
|
|
|
after, issue := projectConfiguration(schema, reflect.ValueOf(*configuration), defaultProjectionLimits())
|
|
if issue != nil {
|
|
state.addDiagnostic(CodeResolveInvalidValue, issue.path, "application resolution produced an invalid configuration value")
|
|
return resolutionResult{diagnostics: canonicalDiagnostics(state.diagnostics)}
|
|
}
|
|
deltas := compareDocumentTrees(decoded.final, after)
|
|
ordered := orderedResolutionRecords(state.records)
|
|
for _, delta := range deltas {
|
|
if coveringResolutionRecord(ordered, delta.path) == nil {
|
|
state.addDiagnostic(CodeResolveUnrecordedChange, delta.path, "application resolution changed a value without a covering record")
|
|
}
|
|
}
|
|
for _, record := range ordered {
|
|
beforeNode, beforePresent := documentAtPath(decoded.final, record.path)
|
|
afterNode, afterPresent := documentAtPath(after, record.path)
|
|
if !beforePresent && !afterPresent {
|
|
state.addDiagnostic(CodeResolveInvalidRecord, record.path, "resolution record does not identify an existing or removed value")
|
|
continue
|
|
}
|
|
if record.helperExpected != nil && (!afterPresent || !documentNodesEqual(record.helperExpected, afterNode)) {
|
|
state.addDiagnostic(CodeResolveInvalidTarget, record.path, "resolution helper assignment does not match its declared configuration path")
|
|
}
|
|
_ = beforeNode
|
|
}
|
|
if hasErrors(state.diagnostics) {
|
|
return resolutionResult{diagnostics: canonicalDiagnostics(state.diagnostics)}
|
|
}
|
|
|
|
snapshots := make([]resolutionSnapshot, 0, len(ordered))
|
|
for _, record := range ordered {
|
|
beforeNode, _ := documentAtPath(decoded.final, record.path)
|
|
afterNode, _ := documentAtPath(after, record.path)
|
|
snapshots = append(snapshots, resolutionSnapshot{
|
|
change: Change{
|
|
Path: record.path.clone(),
|
|
Origin: Origin{Layer: LayerResolution, Operation: record.kind},
|
|
Reasons: append([]string(nil), record.reasons...),
|
|
Dependencies: clonePaths(record.dependencies),
|
|
},
|
|
before: document.Clone(beforeNode), after: document.Clone(afterNode),
|
|
})
|
|
}
|
|
return resolutionResult{
|
|
final: after, deltas: deltas, snapshots: snapshots,
|
|
diagnostics: canonicalDiagnostics(state.diagnostics),
|
|
}
|
|
}
|
|
|
|
func orderedResolutionRecords(records map[string]*resolutionRecord) []*resolutionRecord {
|
|
ordered := make([]*resolutionRecord, 0, len(records))
|
|
for _, record := range records {
|
|
ordered = append(ordered, record)
|
|
}
|
|
slices.SortFunc(ordered, func(left, right *resolutionRecord) int {
|
|
return strings.Compare(left.path.String(), right.path.String())
|
|
})
|
|
return ordered
|
|
}
|
|
|
|
func coveringResolutionRecord(records []*resolutionRecord, path Path) *resolutionRecord {
|
|
var covering *resolutionRecord
|
|
for _, record := range records {
|
|
if pathContains(record.path, path) && (covering == nil || len(record.path.segments) > len(covering.path.segments)) {
|
|
covering = record
|
|
}
|
|
}
|
|
return covering
|
|
}
|
|
|
|
func pathContains(container, descendant Path) bool {
|
|
if !container.valid() || !descendant.valid() || len(container.segments) > len(descendant.segments) {
|
|
return false
|
|
}
|
|
if container.String() == "" {
|
|
return true
|
|
}
|
|
prefix := container.String()
|
|
value := descendant.String()
|
|
return value == prefix || strings.HasPrefix(value, prefix+"/")
|
|
}
|
|
|
|
func appendUniqueString(values []string, value string) []string {
|
|
if !slices.Contains(values, value) {
|
|
return append(values, value)
|
|
}
|
|
return values
|
|
}
|
|
|
|
func appendUniquePath(paths []Path, path Path) []Path {
|
|
for _, existing := range paths {
|
|
if existing.String() == path.String() {
|
|
return paths
|
|
}
|
|
}
|
|
return append(paths, path.clone())
|
|
}
|
|
|
|
func clonePaths(paths []Path) []Path {
|
|
if paths == nil {
|
|
return nil
|
|
}
|
|
cloned := make([]Path, len(paths))
|
|
for index, path := range paths {
|
|
cloned[index] = path.clone()
|
|
}
|
|
return cloned
|
|
}
|
|
|
|
func cloneResolutionSnapshots(snapshots []resolutionSnapshot) []resolutionSnapshot {
|
|
if snapshots == nil {
|
|
return nil
|
|
}
|
|
cloned := make([]resolutionSnapshot, len(snapshots))
|
|
for index, snapshot := range snapshots {
|
|
cloned[index] = resolutionSnapshot{
|
|
change: cloneChange(snapshot.change),
|
|
before: document.Clone(snapshot.before), after: document.Clone(snapshot.after),
|
|
}
|
|
}
|
|
return cloned
|
|
}
|