Files
ConfigKit/internal/engine/resolution.go

434 lines
14 KiB
Go

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 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.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)
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 newResolutionCoverageIndex(paths []Path) *resolutionCoverageIndex {
index := &resolutionCoverageIndex{depths: make([]int, len(paths))}
for id, path := range paths {
index.depths[id] = len(path.segments)
if path.valid() {
index.prefix.insert(path.String(), id)
}
}
return index
}
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 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 pathSpellingEqual(existing, path) {
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
}