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 reasonSet map[string]struct{} dependencySet map[string]struct{} } func (record *resolutionRecord) addReason(reason string) { if record.reasonSet == nil { record.reasonSet = make(map[string]struct{}, len(record.reasons)+1) for _, existing := range record.reasons { record.reasonSet[existing] = struct{}{} } } if _, found := record.reasonSet[reason]; found { return } record.reasonSet[reason] = struct{}{} record.reasons = append(record.reasons, reason) } func (record *resolutionRecord) addDependency(path Path) { pointer := path.String() if record.dependencySet == nil { record.dependencySet = make(map[string]struct{}, len(record.dependencies)+1) for _, existing := range record.dependencies { record.dependencySet[existing.String()] = struct{}{} } } if _, found := record.dependencySet[pointer]; found { return } record.dependencySet[pointer] = struct{}{} record.dependencies = append(record.dependencies, path.clone()) } 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 direct or inherited input 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) } // SourceContext returns a passive context during the active resolution // callback. Retaining the callback context after return is unsupported. func (r *Resolution) SourceContext(id string) (ResourceContext, bool) { return r.activeState().input.sourceContext(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.addReason(reason) for _, dependency := range dependencies { record.addDependency(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} var err error func() { defer func() { state.active = false }() err = resolver(configuration, context) }() 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) coverage := newResolutionCoverageIndexFromRecords(ordered) beforeLookup := buildDocumentLookup(decoded.final) afterLookup := buildDocumentLookup(after) for _, delta := range deltas { if _, ok := coverage.deepestCoveringID(delta.path); !ok { state.addDiagnostic(CodeResolveUnrecordedChange, delta.path, "application resolution changed a value without a covering record") } } for _, record := range ordered { _, beforePresent := documentAtPathWithLookup(decoded.final, record.path, beforeLookup) afterNode, afterPresent := documentAtPathWithLookup(after, record.path, afterLookup) 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, _ := documentAtPathWithLookup(decoded.final, record.path, beforeLookup) afterNode, _ := documentAtPathWithLookup(after, record.path, afterLookup) 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 { type orderedRecord struct { record *resolutionRecord pointer string } ordered := make([]orderedRecord, 0, len(records)) for _, record := range records { ordered = append(ordered, orderedRecord{record: record, pointer: record.path.String()}) } slices.SortFunc(ordered, func(left, right orderedRecord) int { return strings.Compare(left.pointer, right.pointer) }) result := make([]*resolutionRecord, len(ordered)) for i, item := range ordered { result[i] = item.record } return result } type resolutionCoverageIndex struct { prefix pathPrefixIndex depths []int } func newResolutionCoverageIndexFromRecords(records []*resolutionRecord) *resolutionCoverageIndex { index := &resolutionCoverageIndex{depths: make([]int, len(records))} for id, record := range records { index.depths[id] = len(record.path.segments) if record.path.valid() { index.prefix.insert(record.path.String(), id) } } return index } func newResolutionCoverageIndexFromSnapshots(snapshots []resolutionSnapshot) (*resolutionCoverageIndex, []string) { index := &resolutionCoverageIndex{depths: make([]int, len(snapshots))} pointers := make([]string, len(snapshots)) for id, snapshot := range snapshots { path := snapshot.change.Path index.depths[id] = len(path.segments) pointers[id] = path.String() if path.valid() { index.prefix.insert(pointers[id], id) } } return index, pointers } func (index *resolutionCoverageIndex) deepestCoveringID(path Path) (int, bool) { if !path.valid() { return 0, false } return index.deepestCoveringPointerID(path.String()) } func (index *resolutionCoverageIndex) deepestCoveringPointerID(pointer string) (int, bool) { bestID, bestDepth, found := 0, -1, false index.prefix.visitAncestors(pointer, func(id int) { depth := index.depths[id] if !found || depth > bestDepth || (depth == bestDepth && id < bestID) { bestID, bestDepth, found = id, depth, true } }) return bestID, found } func pathContains(container, descendant Path) bool { if !container.valid() || !descendant.valid() || len(container.segments) > len(descendant.segments) { return false } for i, segment := range container.segments { if !pathTokenSpellingEqual(segment, descendant.segments[i]) { return false } } return true } 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 }