438 lines
15 KiB
Go
438 lines
15 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
|
|
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}
|
|
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 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
|
|
}
|