Centralize D&D source reference ordering
This commit is contained in:
107
internal/modules/dnd/shared/source_ref_order.go
Normal file
107
internal/modules/dnd/shared/source_ref_order.go
Normal file
@@ -0,0 +1,107 @@
|
||||
package shared
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||
)
|
||||
|
||||
// SourceRefOrder provides a stable snapshot of a source document's unit
|
||||
// ordering for source-reference comparison and canonicalization.
|
||||
type SourceRefOrder struct {
|
||||
sourceID string
|
||||
positions map[int]int
|
||||
}
|
||||
|
||||
// NewSourceRefOrder captures the source identity and unit positions from doc.
|
||||
func NewSourceRefOrder(doc *source.SourceDocument) SourceRefOrder {
|
||||
if doc == nil {
|
||||
return SourceRefOrder{}
|
||||
}
|
||||
positions := make(map[int]int, len(doc.Units))
|
||||
for position, unit := range doc.Units {
|
||||
if _, exists := positions[unit.ID]; !exists {
|
||||
positions[unit.ID] = position
|
||||
}
|
||||
}
|
||||
return SourceRefOrder{sourceID: doc.ID, positions: positions}
|
||||
}
|
||||
|
||||
// Less orders references by source identity, then document positions when
|
||||
// available, and finally literal endpoint IDs.
|
||||
func (o SourceRefOrder) Less(left, right source.SourceRef) bool {
|
||||
if left.SourceID != right.SourceID {
|
||||
return left.SourceID < right.SourceID
|
||||
}
|
||||
positions := o.positionsFor(left.SourceID)
|
||||
if lessEndpoint(positions, left.StartUnitID, right.StartUnitID) {
|
||||
return true
|
||||
}
|
||||
if lessEndpoint(o.positions, right.StartUnitID, left.StartUnitID) {
|
||||
return false
|
||||
}
|
||||
return lessEndpoint(positions, left.EndUnitID, right.EndUnitID)
|
||||
}
|
||||
|
||||
// EarliestValid returns the earliest document position among valid refs.
|
||||
func (o SourceRefOrder) EarliestValid(refs []source.SourceRef) (int, bool) {
|
||||
if len(refs) == 0 || o.sourceID == "" {
|
||||
return 0, false
|
||||
}
|
||||
found := false
|
||||
earliest := 0
|
||||
for _, ref := range refs {
|
||||
if ref.SourceID != o.sourceID || strings.TrimSpace(ref.SourceID) != ref.SourceID || ref.StartUnitID <= 0 || ref.EndUnitID <= 0 {
|
||||
continue
|
||||
}
|
||||
start, startOK := o.positions[ref.StartUnitID]
|
||||
end, endOK := o.positions[ref.EndUnitID]
|
||||
if !startOK || !endOK || start > end {
|
||||
continue
|
||||
}
|
||||
if !found || start < earliest {
|
||||
earliest = start
|
||||
found = true
|
||||
}
|
||||
}
|
||||
return earliest, found
|
||||
}
|
||||
|
||||
// Canonicalize returns an owned, stable-sorted, exactly de-duplicated copy of
|
||||
// refs. It deliberately preserves invalid references for diagnostics.
|
||||
func (o SourceRefOrder) Canonicalize(refs []source.SourceRef) []source.SourceRef {
|
||||
if refs == nil {
|
||||
return nil
|
||||
}
|
||||
canonical := append([]source.SourceRef{}, refs...)
|
||||
sort.SliceStable(canonical, func(left, right int) bool {
|
||||
return o.Less(canonical[left], canonical[right])
|
||||
})
|
||||
unique := make([]source.SourceRef, 0, len(canonical))
|
||||
for _, ref := range canonical {
|
||||
if len(unique) == 0 || unique[len(unique)-1] != ref {
|
||||
unique = append(unique, ref)
|
||||
}
|
||||
}
|
||||
return unique
|
||||
}
|
||||
|
||||
func (o SourceRefOrder) positionsFor(sourceID string) map[int]int {
|
||||
if o.sourceID == "" || sourceID != o.sourceID {
|
||||
return nil
|
||||
}
|
||||
return o.positions
|
||||
}
|
||||
|
||||
func lessEndpoint(positions map[int]int, left, right int) bool {
|
||||
leftPosition, leftOK := positions[left]
|
||||
rightPosition, rightOK := positions[right]
|
||||
if leftOK != rightOK {
|
||||
return leftOK
|
||||
}
|
||||
if leftOK && leftPosition != rightPosition {
|
||||
return leftPosition < rightPosition
|
||||
}
|
||||
return left < right
|
||||
}
|
||||
81
internal/modules/dnd/shared/source_ref_order_test.go
Normal file
81
internal/modules/dnd/shared/source_ref_order_test.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package shared
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||
)
|
||||
|
||||
func TestSourceRefOrderUsesDocumentOrderAndLiteralFallbacks(t *testing.T) {
|
||||
doc := unitRefSourceDocument(30, 10, 20)
|
||||
order := NewSourceRefOrder(doc)
|
||||
|
||||
refs := []source.SourceRef{
|
||||
{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10},
|
||||
{SourceID: doc.ID, StartUnitID: 999, EndUnitID: 999},
|
||||
{SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30},
|
||||
{SourceID: "other", StartUnitID: 20, EndUnitID: 20},
|
||||
{SourceID: "other", StartUnitID: 10, EndUnitID: 10},
|
||||
}
|
||||
got := order.Canonicalize(refs)
|
||||
want := []source.SourceRef{
|
||||
{SourceID: "other", StartUnitID: 10, EndUnitID: 10},
|
||||
{SourceID: "other", StartUnitID: 20, EndUnitID: 20},
|
||||
{SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30},
|
||||
{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10},
|
||||
{SourceID: doc.ID, StartUnitID: 999, EndUnitID: 999},
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("Canonicalize() = %#v, want %#v", got, want)
|
||||
}
|
||||
if order.Less(want[0], want[0]) {
|
||||
t.Fatal("Less(ref, ref) = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSourceRefOrderCanonicalizePreservesRepresentationAndOwnership(t *testing.T) {
|
||||
order := NewSourceRefOrder(unitRefSourceDocument(3, 1, 2))
|
||||
if got := order.Canonicalize(nil); got != nil {
|
||||
t.Fatalf("Canonicalize(nil) = %#v, want nil", got)
|
||||
}
|
||||
empty := []source.SourceRef{}
|
||||
if got := order.Canonicalize(empty); got == nil || len(got) != 0 {
|
||||
t.Fatalf("Canonicalize(empty) = %#v, want owned empty slice", got)
|
||||
}
|
||||
input := []source.SourceRef{
|
||||
{SourceID: "session-alpha", StartUnitID: 1, EndUnitID: 2},
|
||||
{SourceID: "session-alpha", StartUnitID: 1, EndUnitID: 2},
|
||||
{SourceID: "session-alpha", StartUnitID: 1, EndUnitID: 3},
|
||||
}
|
||||
got := order.Canonicalize(input)
|
||||
if len(got) != 2 || got[0] == got[1] {
|
||||
t.Fatalf("Canonicalize() = %#v, want exact duplicate removed but distinct range retained", got)
|
||||
}
|
||||
got[0].StartUnitID = 99
|
||||
if input[0].StartUnitID == 99 {
|
||||
t.Fatal("Canonicalize() output aliases input")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSourceRefOrderSnapshotAndEarliestValid(t *testing.T) {
|
||||
doc := unitRefSourceDocument(30, 10, 20)
|
||||
order := NewSourceRefOrder(doc)
|
||||
doc.Units[0].ID, doc.Units[1].ID = 10, 30
|
||||
refs := []source.SourceRef{
|
||||
{SourceID: doc.ID, StartUnitID: 20, EndUnitID: 20},
|
||||
{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10},
|
||||
{SourceID: "other", StartUnitID: 1, EndUnitID: 1},
|
||||
{SourceID: doc.ID, StartUnitID: 999, EndUnitID: 999},
|
||||
{SourceID: doc.ID, StartUnitID: 20, EndUnitID: 30},
|
||||
}
|
||||
if position, ok := order.EarliestValid(refs); !ok || position != 1 {
|
||||
t.Fatalf("EarliestValid() = %d, %t, want 1, true", position, ok)
|
||||
}
|
||||
if position, ok := order.EarliestValid([]source.SourceRef{{SourceID: doc.ID, StartUnitID: 20, EndUnitID: 30}}); ok || position != 0 {
|
||||
t.Fatalf("EarliestValid(invalid) = %d, %t, want 0, false", position, ok)
|
||||
}
|
||||
if position, ok := (SourceRefOrder{}).EarliestValid(refs); ok || position != 0 {
|
||||
t.Fatalf("zero EarliestValid() = %d, %t, want 0, false", position, ok)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user