542 lines
19 KiB
Go
542 lines
19 KiB
Go
package engine
|
|
|
|
import "gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
|
|
|
// composedDocument is the private handoff between source assembly and later
|
|
// precedence layers. Claims remain ordered by source and declaration traversal;
|
|
// origins retain every source that contributes to a composite mapping.
|
|
type composedDocument struct {
|
|
root *document.Node
|
|
acquisition []acquisitionScope
|
|
importGraph importInclusionGraph
|
|
claims []compositionClaim
|
|
sequenceRanges []sequenceRange
|
|
origins map[string]Origin
|
|
overlays []overlayRecord
|
|
applications []historyApplication
|
|
inputBytes int64
|
|
inputNodes int
|
|
}
|
|
|
|
type sequenceHistoryMode uint8
|
|
|
|
const (
|
|
sequenceHistoryReplace sequenceHistoryMode = iota + 1
|
|
sequenceHistoryAppend
|
|
sequenceHistoryMapped
|
|
)
|
|
|
|
type sequenceHistoryContribution struct {
|
|
path Path
|
|
mode sequenceHistoryMode
|
|
offset int
|
|
length int
|
|
items []sequenceItemProjection
|
|
replacements []sequenceItemReplacement
|
|
}
|
|
|
|
// assemblyAccumulator owns one mutable working document. Acquired trees held by
|
|
// history applications are separate from its root and remain unchanged.
|
|
type assemblyAccumulator struct {
|
|
current *composedDocument
|
|
index mappingIndex
|
|
}
|
|
|
|
func newAssemblyAccumulator(owned *composedDocument) *assemblyAccumulator {
|
|
return &assemblyAccumulator{current: owned}
|
|
}
|
|
|
|
// cloneComposedDocument detaches mutable composition and provenance state;
|
|
// history application trees are immutable and retain their existing sharing.
|
|
func cloneComposedDocument(base *composedDocument) *composedDocument {
|
|
if base == nil {
|
|
return nil
|
|
}
|
|
return &composedDocument{
|
|
root: document.Clone(base.root), claims: cloneCompositionClaims(base.claims),
|
|
acquisition: cloneAcquisitionScopes(base.acquisition),
|
|
importGraph: base.importGraph.clone(),
|
|
sequenceRanges: cloneSequenceRanges(base.sequenceRanges),
|
|
origins: cloneOriginIndex(base.origins), overlays: cloneOverlayRecords(base.overlays),
|
|
applications: cloneHistoryApplications(base.applications),
|
|
inputBytes: base.inputBytes, inputNodes: base.inputNodes,
|
|
}
|
|
}
|
|
|
|
type compositionClaim struct {
|
|
node *document.Node // borrowed immutable contribution, not the assembly tree
|
|
path Path
|
|
kind document.Kind
|
|
location Location
|
|
sourceIndex int
|
|
}
|
|
|
|
type compositionClaims struct {
|
|
ordered []compositionClaim
|
|
byPath map[string][]compositionClaim
|
|
}
|
|
|
|
// sequenceRange records where one source's sequence items were assigned in the
|
|
// composed sequence at path. End is exclusive.
|
|
type sequenceRange struct {
|
|
sourceIndex int
|
|
path Path
|
|
start int
|
|
end int
|
|
items []sequenceItemProjection
|
|
projection sequenceItemProjectionIndex
|
|
}
|
|
|
|
func (assigned sequenceRange) projections() []sequenceItemProjection {
|
|
if assigned.items != nil {
|
|
return append([]sequenceItemProjection{}, assigned.items...)
|
|
}
|
|
return sequenceItemProjections(assigned.end-assigned.start, assigned.start)
|
|
}
|
|
|
|
func (assigned sequenceRange) outputForInput(inputIndex int) (int, bool) {
|
|
if assigned.projection.outputByInput != nil {
|
|
return assigned.projection.outputForInput(inputIndex)
|
|
}
|
|
if assigned.items != nil {
|
|
return newSequenceItemProjectionIndex(assigned.items).outputForInput(inputIndex)
|
|
}
|
|
if inputIndex < 0 || inputIndex >= assigned.end-assigned.start {
|
|
return 0, false
|
|
}
|
|
return assigned.start + inputIndex, true
|
|
}
|
|
|
|
func (assigned sequenceRange) inputForOutput(outputIndex int) (int, bool) {
|
|
if assigned.projection.inputByOutput != nil {
|
|
return assigned.projection.inputForOutput(outputIndex)
|
|
}
|
|
if assigned.items != nil {
|
|
return newSequenceItemProjectionIndex(assigned.items).inputForOutput(outputIndex)
|
|
}
|
|
if outputIndex < assigned.start || outputIndex >= assigned.end {
|
|
return 0, false
|
|
}
|
|
return outputIndex - assigned.start, true
|
|
}
|
|
|
|
func indexSequenceRange(assigned sequenceRange) sequenceRange {
|
|
if assigned.projection.outputByInput == nil && assigned.projection.inputByOutput == nil {
|
|
assigned.projection = newSequenceItemProjectionIndex(assigned.projections())
|
|
}
|
|
return assigned
|
|
}
|
|
|
|
// composeDocuments recursively joins disjoint mapping entries. Source order
|
|
// controls only output traversal and diagnostics: every non-mapping overlap is
|
|
// rejected before a result is assembled.
|
|
func composeDocuments(inputs ...*document.Node) (*composedDocument, []Diagnostic) {
|
|
return composeDocumentsWithPolicy(sequencePolicy{}, inputs...)
|
|
}
|
|
|
|
// composeDocumentsWithPolicy composes one strict tier using the registered
|
|
// sequence rules when policy contains registrations. The ordinary adapter above
|
|
// deliberately preserves the existing unregistered behavior for current callers.
|
|
func composeDocumentsWithPolicy(policy sequencePolicy, inputs ...*document.Node) (*composedDocument, []Diagnostic) {
|
|
if len(inputs) == 0 {
|
|
return nil, []Diagnostic{compositionInvalidDiagnostic("strict composition requires at least one document", nil)}
|
|
}
|
|
|
|
collected := compositionClaims{byPath: make(map[string][]compositionClaim)}
|
|
for sourceIndex, input := range inputs {
|
|
if input == nil || input.Kind != document.Mapping {
|
|
var location *Location
|
|
if input != nil {
|
|
converted := publicLocation(input.Location)
|
|
location = &converted
|
|
}
|
|
return nil, []Diagnostic{compositionInvalidDiagnostic("strict composition requires mapping document roots", location)}
|
|
}
|
|
collected.append(input, Path{}, sourceIndex)
|
|
}
|
|
|
|
checked := make(map[string]struct{}, len(collected.byPath))
|
|
for _, claim := range collected.ordered {
|
|
pointer := claim.path.String()
|
|
if _, seen := checked[pointer]; seen {
|
|
continue
|
|
}
|
|
checked[pointer] = struct{}{}
|
|
claims := collected.byPath[pointer]
|
|
if len(claims) < 2 || allMappingClaims(claims) || (allSequenceClaims(claims) && policy.registered(claim.path)) {
|
|
continue
|
|
}
|
|
return nil, []Diagnostic{compositionConflictDiagnostic(claim.path, claims)}
|
|
}
|
|
|
|
validated, ranges, diagnostics := validateRegisteredSequences(collected.ordered, policy)
|
|
if len(diagnostics) != 0 {
|
|
return nil, diagnostics
|
|
}
|
|
|
|
root := document.Clone(inputs[0])
|
|
index := new(mappingIndex)
|
|
for sourceIndex, input := range inputs[1:] {
|
|
mergeComposedMappings(root, input, Path{}, index, sourceIndex+1, policy, validated)
|
|
}
|
|
applications := make([]historyApplication, 0, len(inputs))
|
|
seenSequences := make(map[string]bool)
|
|
rangesBySource := make([][]sequenceRange, len(inputs))
|
|
for _, assigned := range ranges {
|
|
rangesBySource[assigned.sourceIndex] = append(rangesBySource[assigned.sourceIndex], assigned)
|
|
}
|
|
for sourceIndex, input := range inputs {
|
|
application := historyApplication{layer: LayerBase, value: input}
|
|
for _, assigned := range rangesBySource[sourceIndex] {
|
|
pointer := assigned.path.String()
|
|
mode := sequenceHistoryAppend
|
|
if !seenSequences[pointer] {
|
|
mode = sequenceHistoryReplace
|
|
}
|
|
seenSequences[pointer] = true
|
|
application.sequences = append(application.sequences, sequenceHistoryContribution{
|
|
path: assigned.path.clone(), mode: mode, offset: assigned.start, length: assigned.end - assigned.start,
|
|
})
|
|
}
|
|
application.sequencesByPath = indexSequenceHistoryContributions(application.sequences)
|
|
applications = append(applications, application)
|
|
}
|
|
origins := projectedCompositionOrigins(inputs, ranges)
|
|
completeCompositionOrigins(root, Path{}, origins)
|
|
return &composedDocument{
|
|
root: root,
|
|
claims: cloneCompositionClaims(collected.ordered),
|
|
sequenceRanges: cloneSequenceRanges(ranges),
|
|
origins: origins,
|
|
applications: applications,
|
|
}, nil
|
|
}
|
|
|
|
// Conflict claims treat sequences atomically, but inspection needs origins for
|
|
// every descendant. Preserve composite mapping claims while filling those gaps.
|
|
func completeCompositionOrigins(node *document.Node, path Path, origins map[string]Origin) {
|
|
pointer := path.String()
|
|
if _, present := origins[pointer]; !present {
|
|
origins[pointer] = Origin{Layer: LayerBase, Locations: []Location{publicLocation(node.Location)}}
|
|
}
|
|
switch node.Kind {
|
|
case document.Mapping:
|
|
for _, entry := range node.Mapping {
|
|
completeCompositionOrigins(entry.Value, path.Key(entry.Key), origins)
|
|
}
|
|
case document.Sequence:
|
|
for index, child := range node.Sequence {
|
|
completeCompositionOrigins(child, path.Index(index), origins)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (c *compositionClaims) append(node *document.Node, path Path, sourceIndex int) {
|
|
claim := compositionClaim{
|
|
node: node,
|
|
path: path.clone(),
|
|
kind: node.Kind,
|
|
location: publicLocation(node.Location),
|
|
sourceIndex: sourceIndex,
|
|
}
|
|
c.ordered = append(c.ordered, claim)
|
|
pointer := path.String()
|
|
c.byPath[pointer] = append(c.byPath[pointer], claim)
|
|
|
|
if node.Kind != document.Mapping {
|
|
return
|
|
}
|
|
for _, entry := range node.Mapping {
|
|
c.append(entry.Value, path.Key(entry.Key), sourceIndex)
|
|
}
|
|
}
|
|
|
|
func allSequenceClaims(claims []compositionClaim) bool {
|
|
for _, claim := range claims {
|
|
if claim.kind != document.Sequence {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
type validatedSequence struct {
|
|
items []*document.Node
|
|
}
|
|
|
|
func validateRegisteredSequences(claims []compositionClaim, policy sequencePolicy) (map[string]map[int]validatedSequence, []sequenceRange, []Diagnostic) {
|
|
itemsByPath := make(map[string]map[int]validatedSequence)
|
|
identitiesByPath := make(map[string]map[string]Location)
|
|
lengthsByPath := make(map[string]int)
|
|
var ranges []sequenceRange
|
|
for _, claim := range claims {
|
|
if claim.kind != document.Sequence {
|
|
continue
|
|
}
|
|
identityFields, registered := policy.identityFieldsAt(claim.path)
|
|
if !registered {
|
|
continue
|
|
}
|
|
node := claim.node
|
|
pathKey := claim.path.String()
|
|
identities := identitiesByPath[pathKey]
|
|
if identities == nil {
|
|
identities = make(map[string]Location, len(node.Sequence))
|
|
identitiesByPath[pathKey] = identities
|
|
}
|
|
start := lengthsByPath[pathKey]
|
|
contribution := validatedSequence{items: node.Sequence}
|
|
for itemIndex, item := range node.Sequence {
|
|
itemPath := claim.path.Index(start + itemIndex)
|
|
if item == nil || item.Kind != document.Mapping {
|
|
location := (*Location)(nil)
|
|
if item != nil {
|
|
converted := publicLocation(item.Location)
|
|
location = &converted
|
|
}
|
|
return nil, nil, []Diagnostic{{
|
|
Code: CodeComposeInvalid, Severity: SeverityError,
|
|
Path: itemPath, Message: "registered sequence items must be mappings", Primary: location,
|
|
}}
|
|
}
|
|
var identity string
|
|
var identityLocation document.Location
|
|
if legacyField, legacy := policy.identityFields[claim.path.String()]; legacy {
|
|
fieldIndex := documentEntryIndex(item.Mapping, legacyField)
|
|
if fieldIndex < 0 {
|
|
location := publicLocation(item.Location)
|
|
return nil, nil, []Diagnostic{{
|
|
Code: CodeComposeInvalid, Severity: SeverityError,
|
|
Path: itemPath, Message: "registered sequence item is missing its identity field",
|
|
Primary: &location,
|
|
}}
|
|
}
|
|
field := item.Mapping[fieldIndex].Value
|
|
if field == nil || field.Kind != document.String {
|
|
location := publicLocation(item.Mapping[fieldIndex].ValueLocation)
|
|
if field != nil {
|
|
location = publicLocation(field.Location)
|
|
}
|
|
return nil, nil, []Diagnostic{{
|
|
Code: CodeComposeInvalid, Severity: SeverityError,
|
|
Path: itemPath.Key(legacyField), Message: "registered sequence identity must be a string",
|
|
Primary: &location,
|
|
}}
|
|
}
|
|
identity, identityLocation = field.Text, field.Location
|
|
} else {
|
|
mapping := newSequenceMappingIndex(item)
|
|
var locations []document.Location
|
|
var fieldName string
|
|
var fieldIndex int
|
|
identity, locations, fieldName, fieldIndex = mapping.identity(identityFields)
|
|
if fieldIndex == -1 {
|
|
location := publicLocation(item.Location)
|
|
return nil, nil, []Diagnostic{{
|
|
Code: CodeComposeInvalid, Severity: SeverityError,
|
|
Path: itemPath.Key(fieldName), Message: "registered sequence item is missing an identity field",
|
|
Primary: &location,
|
|
}}
|
|
}
|
|
if fieldIndex == -2 || fieldIndex >= 0 {
|
|
location := publicLocation(item.Location)
|
|
fieldPath := itemPath.Key(fieldName)
|
|
if fieldIndex >= 0 {
|
|
location = publicLocation(item.Mapping[fieldIndex].ValueLocation)
|
|
if field := item.Mapping[fieldIndex].Value; field != nil {
|
|
location = publicLocation(field.Location)
|
|
}
|
|
}
|
|
return nil, nil, []Diagnostic{{
|
|
Code: CodeComposeInvalid, Severity: SeverityError,
|
|
Path: fieldPath, Message: "registered sequence identity fields must be scalar values",
|
|
Primary: &location,
|
|
}}
|
|
}
|
|
identityLocation = locations[0]
|
|
}
|
|
if first, duplicate := identities[identity]; duplicate {
|
|
current := publicLocation(identityLocation)
|
|
return nil, nil, []Diagnostic{{
|
|
Code: CodeComposeConflict, Severity: SeverityError,
|
|
Path: claim.path.clone(), Message: "registered sequence contains duplicate identities",
|
|
Primary: &first, Related: []Location{current},
|
|
}}
|
|
}
|
|
identities[identity] = publicLocation(identityLocation)
|
|
}
|
|
if itemsByPath[pathKey] == nil {
|
|
itemsByPath[pathKey] = make(map[int]validatedSequence)
|
|
}
|
|
itemsByPath[pathKey][claim.sourceIndex] = contribution
|
|
lengthsByPath[pathKey] = start + len(node.Sequence)
|
|
ranges = append(ranges, sequenceRange{
|
|
sourceIndex: claim.sourceIndex, path: claim.path.clone(), start: start, end: start + len(node.Sequence),
|
|
})
|
|
}
|
|
return itemsByPath, ranges, nil
|
|
}
|
|
|
|
func mergeComposedMappings(target, incoming *document.Node, path Path, index *mappingIndex, sourceIndex int, policy sequencePolicy, validated map[string]map[int]validatedSequence) {
|
|
for _, incomingEntry := range incoming.Mapping {
|
|
entryPath := path.Key(incomingEntry.Key)
|
|
position := index.find(target, incomingEntry.Key)
|
|
if position < 0 {
|
|
cloned := incomingEntry
|
|
cloned.Value = document.Clone(incomingEntry.Value)
|
|
index.append(target, cloned)
|
|
continue
|
|
}
|
|
current := target.Mapping[position].Value
|
|
if current.Kind == document.Sequence && incomingEntry.Value.Kind == document.Sequence && policy.registered(entryPath) {
|
|
contribution := validated[entryPath.String()][sourceIndex]
|
|
if contribution.items != nil {
|
|
for _, item := range contribution.items {
|
|
current.Sequence = append(current.Sequence, document.Clone(item))
|
|
}
|
|
}
|
|
continue
|
|
}
|
|
mergeDisjointMappingsAtPath(current, incomingEntry.Value, entryPath, index, sourceIndex, policy, validated)
|
|
}
|
|
}
|
|
|
|
func mergeDisjointMappingsAtPath(target, incoming *document.Node, path Path, index *mappingIndex, sourceIndex int, policy sequencePolicy, validated map[string]map[int]validatedSequence) {
|
|
if target.Kind != document.Mapping || incoming.Kind != document.Mapping {
|
|
return
|
|
}
|
|
mergeComposedMappings(target, incoming, path, index, sourceIndex, policy, validated)
|
|
}
|
|
|
|
func cloneSequenceRanges(ranges []sequenceRange) []sequenceRange {
|
|
cloned := make([]sequenceRange, len(ranges))
|
|
for i, assigned := range ranges {
|
|
cloned[i] = assigned
|
|
cloned[i].path = assigned.path.clone()
|
|
if assigned.items != nil {
|
|
cloned[i].items = append([]sequenceItemProjection{}, assigned.items...)
|
|
}
|
|
cloned[i].projection.outputByInput = append([]int(nil), assigned.projection.outputByInput...)
|
|
cloned[i].projection.inputByOutput = append([]int(nil), assigned.projection.inputByOutput...)
|
|
}
|
|
return cloned
|
|
}
|
|
|
|
func allMappingClaims(claims []compositionClaim) bool {
|
|
for _, claim := range claims {
|
|
if claim.kind != document.Mapping {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func mergeDisjointMappings(target, incoming *document.Node, index *mappingIndex) {
|
|
for _, incomingEntry := range incoming.Mapping {
|
|
position := index.find(target, incomingEntry.Key)
|
|
if position < 0 {
|
|
cloned := incomingEntry
|
|
cloned.Value = document.Clone(incomingEntry.Value)
|
|
index.append(target, cloned)
|
|
continue
|
|
}
|
|
// Claim validation guarantees that the only remaining overlap is a
|
|
// mapping pair. Empty mappings naturally contribute no child entries.
|
|
mergeDisjointMappings(target.Mapping[position].Value, incomingEntry.Value, index)
|
|
}
|
|
}
|
|
|
|
func documentEntryIndex(entries []document.Entry, key string) int {
|
|
for index := range entries {
|
|
if entries[index].Key == key {
|
|
return index
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
func projectedCompositionOrigins(inputs []*document.Node, ranges []sequenceRange) map[string]Origin {
|
|
origins := make(map[string]Origin)
|
|
sequencesBySource := make([][]sequenceHistoryContribution, len(inputs))
|
|
for _, assigned := range ranges {
|
|
sequencesBySource[assigned.sourceIndex] = append(sequencesBySource[assigned.sourceIndex], sequenceHistoryContribution{
|
|
path: assigned.path.clone(), mode: sequenceHistoryMapped, items: assigned.projections(),
|
|
})
|
|
}
|
|
for sourceIndex, input := range inputs {
|
|
application := historyApplication{value: input, sequences: sequencesBySource[sourceIndex]}
|
|
application.sequencesByPath = indexSequenceHistoryContributions(application.sequences)
|
|
walkHistoryApplication(application, func(_, effective Path, node *document.Node) {
|
|
pointer := effective.String()
|
|
origin := origins[pointer]
|
|
origin.Layer = LayerBase
|
|
origin.Locations = append(origin.Locations, publicLocation(node.Location))
|
|
origins[pointer] = origin
|
|
})
|
|
}
|
|
return origins
|
|
}
|
|
|
|
type sequenceRangeKey struct {
|
|
sourceIndex int
|
|
path string
|
|
}
|
|
|
|
type sequenceRangeIndex map[sequenceRangeKey]sequenceRange
|
|
|
|
func indexSequenceRanges(ranges []sequenceRange) sequenceRangeIndex {
|
|
if len(ranges) == 0 {
|
|
return nil
|
|
}
|
|
index := make(sequenceRangeIndex, len(ranges))
|
|
for _, assigned := range ranges {
|
|
assigned = indexSequenceRange(assigned)
|
|
index[sequenceRangeKey{sourceIndex: assigned.sourceIndex, path: assigned.path.String()}] = assigned
|
|
}
|
|
return index
|
|
}
|
|
|
|
func applyCompositionOrigins(decoded *decodedConfiguration, origins map[string]Origin) {
|
|
for pointer, origin := range origins {
|
|
if _, present := decoded.presence[pointer]; present {
|
|
decoded.origins[pointer] = cloneOrigin(origin)
|
|
}
|
|
}
|
|
}
|
|
|
|
func cloneCompositionClaims(claims []compositionClaim) []compositionClaim {
|
|
cloned := make([]compositionClaim, len(claims))
|
|
for index, claim := range claims {
|
|
cloned[index] = claim
|
|
cloned[index].path = claim.path.clone()
|
|
}
|
|
return cloned
|
|
}
|
|
|
|
func compositionInvalidDiagnostic(message string, location *Location) Diagnostic {
|
|
return Diagnostic{
|
|
Code: CodeComposeInvalid, Severity: SeverityError, Message: message,
|
|
Primary: cloneLocation(location),
|
|
}
|
|
}
|
|
|
|
func compositionConflictDiagnostic(path Path, claims []compositionClaim) Diagnostic {
|
|
primary := claims[0].location
|
|
related := make([]Location, 0, len(claims)-1)
|
|
for _, claim := range claims[1:] {
|
|
related = append(related, claim.location)
|
|
}
|
|
return Diagnostic{
|
|
Code: CodeComposeConflict,
|
|
Severity: SeverityError,
|
|
Path: path.clone(),
|
|
Message: "configuration path is defined by multiple sources",
|
|
Primary: &primary,
|
|
Related: related,
|
|
}
|
|
}
|
|
|
|
func publicLocation(location document.Location) Location {
|
|
return Location{SourceID: location.SourceID, Line: location.Line, Column: location.Column}
|
|
}
|