package engine 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 input resolutionInput 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 } // InputInfo returns detached entry-state metadata for path. The entry state is // the decoded configuration, including retained static defaults, immediately // before resolution starts. It is fixed while resolution records mutate the // working configuration; unknown or shape-mismatched paths return false. // // InputInfo is valid only during the resolution callback. See the configuration // contract for source and provenance semantics. func (r *Resolution) InputInfo(path Path) (FieldInfo, bool) { return r.activeState().input.lookup(path) } // Source returns a detached record for a source acquired by this load. Empty or // unknown IDs return false and do not perform input, filesystem, or credential // access. A source path is present only when that acquired source has one. // // Source is valid only during the resolution callback. See the configuration // contract for source and provenance semantics. func (r *Resolution) Source(id string) (SourceInfo, bool) { return r.activeState().input.source(id) } // 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, entry resolutionInput) resolutionResult { state := &resolutionState{ active: true, schema: schema, presence: decoded.presence, document: input, input: entry, 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 := compareConfigurationValues(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 { _, 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 || !configurationValuesEqual(record.helperExpected, afterNode)) { state.addDiagnostic(CodeResolveInvalidTarget, record.path, "resolution helper assignment does not match its declared configuration path") } } 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 }