Install command aliases atomically
This commit is contained in:
285
registry.go
285
registry.go
@@ -3,6 +3,10 @@ package commandkit
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
internalalias "gitea.maximumdirect.net/eric/CommandKit/internal/alias"
|
||||
"gitea.maximumdirect.net/eric/CommandKit/internal/commandpath"
|
||||
)
|
||||
|
||||
// Register adds a batch of declarations to the application. The complete batch
|
||||
@@ -13,6 +17,31 @@ func (a *App) Register(declarations ...Declaration) error {
|
||||
return a.installBatch(declarations...)
|
||||
}
|
||||
|
||||
type installationCandidate struct {
|
||||
commands map[string]*commandNode
|
||||
routingRules []RoutingRule
|
||||
aliases *internalalias.Resolver
|
||||
changed bool
|
||||
}
|
||||
|
||||
type canonicalNode struct {
|
||||
command Command
|
||||
path commandpath.Path
|
||||
children map[string]*canonicalNode
|
||||
}
|
||||
|
||||
type commandAliasTrace struct {
|
||||
exact *canonicalNode
|
||||
operandOwner *canonicalNode
|
||||
}
|
||||
|
||||
type routingRuleKey struct {
|
||||
kind routingRuleKind
|
||||
source string
|
||||
target string
|
||||
scope string
|
||||
}
|
||||
|
||||
func (a *App) installBatch(declarations ...Declaration) error {
|
||||
if a == nil {
|
||||
return fmt.Errorf("cannot register commands on a nil app")
|
||||
@@ -42,43 +71,259 @@ func (a *App) installBatch(declarations ...Declaration) error {
|
||||
}
|
||||
}
|
||||
|
||||
pending := make(map[string]*commandNode, len(commands))
|
||||
candidate, err := a.buildInstallationCandidate(commands, rules)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !candidate.changed {
|
||||
return nil
|
||||
}
|
||||
a.commands = candidate.commands
|
||||
a.routingRules = candidate.routingRules
|
||||
a.aliases = candidate.aliases
|
||||
a.snapshot = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) buildInstallationCandidate(commands []Command, addedRules []RoutingRule) (*installationCandidate, error) {
|
||||
candidate := &installationCandidate{
|
||||
commands: make(map[string]*commandNode, len(a.commands)+len(commands)),
|
||||
}
|
||||
for name, node := range a.commands {
|
||||
candidate.commands[name] = node
|
||||
}
|
||||
for _, command := range commands {
|
||||
if err := validateCommandPlacement(command); err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
if command.record.absent {
|
||||
continue
|
||||
}
|
||||
name := command.record.name
|
||||
if _, exists := a.commands[name]; exists {
|
||||
return fmt.Errorf("command path %q is already registered", name)
|
||||
if _, exists := candidate.commands[name]; exists {
|
||||
return nil, fmt.Errorf("command path %q is already registered", name)
|
||||
}
|
||||
if _, exists := pending[name]; exists {
|
||||
return fmt.Errorf("command path %q is already registered", name)
|
||||
}
|
||||
pending[name] = &commandNode{command: command}
|
||||
candidate.commands[name] = &commandNode{command: command}
|
||||
candidate.changed = true
|
||||
}
|
||||
|
||||
rules, err := normalizeRoutingRules(append(append([]RoutingRule(nil), a.routingRules...), addedRules...))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
candidate.routingRules = rules
|
||||
if !sameRoutingRules(rules, a.routingRules) {
|
||||
candidate.changed = true
|
||||
}
|
||||
|
||||
var mappings []internalalias.Mapping
|
||||
for _, rule := range rules {
|
||||
if rule.record.kind == commandAliasRule {
|
||||
mappings = append(mappings, internalalias.Mapping{
|
||||
Source: rule.record.sourcePath.Clone(),
|
||||
Target: rule.record.targetPath.Clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
if err := internalalias.ValidateTermination(mappings); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resolver, err := internalalias.NewResolver(mappings)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
candidate.aliases = resolver
|
||||
|
||||
tree := buildCanonicalTree(candidate.commands)
|
||||
if err := validateCommandAliases(rules, resolver, tree); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return candidate, nil
|
||||
}
|
||||
|
||||
func normalizeRoutingRules(rules []RoutingRule) ([]RoutingRule, error) {
|
||||
unique := make(map[routingRuleKey]RoutingRule, len(rules))
|
||||
commandTargets := make(map[string]string)
|
||||
for _, rule := range rules {
|
||||
if rule.record == nil {
|
||||
return fmt.Errorf("invalid zero routing rule declaration")
|
||||
return nil, fmt.Errorf("invalid zero routing rule declaration")
|
||||
}
|
||||
if len(rule.record.issues) > 0 {
|
||||
return fmt.Errorf("routing rule: %w", errors.Join(rule.record.issues...))
|
||||
return nil, fmt.Errorf("routing rule: %w", errors.Join(rule.record.issues...))
|
||||
}
|
||||
key, err := routingKey(rule.record)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if key.kind == commandAliasRule {
|
||||
if previous, exists := commandTargets[key.source]; exists && previous != key.target {
|
||||
return nil, fmt.Errorf("conflicting command aliases for %q", rule.record.sourcePath.String())
|
||||
}
|
||||
commandTargets[key.source] = key.target
|
||||
}
|
||||
unique[key] = rule
|
||||
}
|
||||
keys := make([]routingRuleKey, 0, len(unique))
|
||||
for key := range unique {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Slice(keys, func(i, j int) bool {
|
||||
left, right := keys[i], keys[j]
|
||||
if left.kind != right.kind {
|
||||
return left.kind < right.kind
|
||||
}
|
||||
if left.source != right.source {
|
||||
return left.source < right.source
|
||||
}
|
||||
if left.scope != right.scope {
|
||||
return left.scope < right.scope
|
||||
}
|
||||
return left.target < right.target
|
||||
})
|
||||
result := make([]RoutingRule, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
result = append(result, unique[key])
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func routingKey(record *routingRuleRecord) (routingRuleKey, error) {
|
||||
key := routingRuleKey{kind: record.kind}
|
||||
switch record.kind {
|
||||
case commandAliasRule:
|
||||
if len(record.sourcePath) == 0 || len(record.targetPath) == 0 {
|
||||
return routingRuleKey{}, fmt.Errorf("invalid command alias path")
|
||||
}
|
||||
key.source = record.sourcePath.Key()
|
||||
key.target = record.targetPath.Key()
|
||||
case globalFlagAliasRule:
|
||||
key.source = flagSpellingKey(record.sourceFlag)
|
||||
key.target = flagSpellingKey(record.targetFlag)
|
||||
case scopedFlagAliasRule:
|
||||
if len(record.scopePath) == 0 {
|
||||
return routingRuleKey{}, fmt.Errorf("invalid flag alias command scope")
|
||||
}
|
||||
key.source = flagSpellingKey(record.sourceFlag)
|
||||
key.target = flagSpellingKey(record.targetFlag)
|
||||
key.scope = record.scopePath.Key()
|
||||
default:
|
||||
return routingRuleKey{}, fmt.Errorf("unsupported routing rule kind %d", record.kind)
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
func flagSpellingKey(spelling capturedFlagSpelling) string {
|
||||
if spelling.short {
|
||||
return "short:" + spelling.name
|
||||
}
|
||||
return "long:" + spelling.name
|
||||
}
|
||||
|
||||
func sameRoutingRules(left, right []RoutingRule) bool {
|
||||
if len(left) != len(right) {
|
||||
return false
|
||||
}
|
||||
leftKeys := make(map[routingRuleKey]struct{}, len(left))
|
||||
for _, rule := range left {
|
||||
key, err := routingKey(rule.record)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
leftKeys[key] = struct{}{}
|
||||
}
|
||||
for _, rule := range right {
|
||||
key, err := routingKey(rule.record)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if _, exists := leftKeys[key]; !exists {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if len(pending) == 0 && len(rules) == 0 {
|
||||
return nil
|
||||
return true
|
||||
}
|
||||
|
||||
func buildCanonicalTree(commands map[string]*commandNode) map[string]*canonicalNode {
|
||||
roots := make(map[string]*canonicalNode, len(commands))
|
||||
for name, node := range commands {
|
||||
roots[name] = buildCanonicalNode(node.command, commandpath.Path{name})
|
||||
}
|
||||
if a.commands == nil {
|
||||
a.commands = make(map[string]*commandNode, len(pending))
|
||||
return roots
|
||||
}
|
||||
|
||||
func buildCanonicalNode(command Command, path commandpath.Path) *canonicalNode {
|
||||
node := &canonicalNode{command: command, path: path.Clone(), children: make(map[string]*canonicalNode, len(command.record.children))}
|
||||
for _, child := range command.record.children {
|
||||
if child.record == nil || child.record.absent {
|
||||
continue
|
||||
}
|
||||
childPath := append(path.Clone(), child.record.name)
|
||||
node.children[child.record.name] = buildCanonicalNode(child, childPath)
|
||||
}
|
||||
for name, node := range pending {
|
||||
a.commands[name] = node
|
||||
}
|
||||
a.routingRules = append(a.routingRules, rules...)
|
||||
if len(pending) > 0 {
|
||||
a.snapshot = nil
|
||||
return node
|
||||
}
|
||||
|
||||
func validateCommandAliases(rules []RoutingRule, resolver *internalalias.Resolver, roots map[string]*canonicalNode) error {
|
||||
for _, rule := range rules {
|
||||
if rule.record.kind != commandAliasRule {
|
||||
continue
|
||||
}
|
||||
record := rule.record
|
||||
sourceTrace := traceCanonicalPath(roots, record.sourcePath)
|
||||
if sourceTrace.exact != nil {
|
||||
return fmt.Errorf("command alias source %q conflicts with canonical command %q", record.sourcePath.String(), sourceTrace.exact.path.String())
|
||||
}
|
||||
if sourceTrace.operandOwner != nil {
|
||||
return fmt.Errorf("command alias source %q intercepts operands of canonical command %q", record.sourcePath.String(), sourceTrace.operandOwner.path.String())
|
||||
}
|
||||
|
||||
resolvedTarget := resolver.Resolve(record.targetPath)
|
||||
trace := traceCanonicalPath(roots, resolvedTarget)
|
||||
if trace.exact != nil {
|
||||
continue
|
||||
}
|
||||
if trace.operandOwner != nil {
|
||||
return fmt.Errorf("command alias %q target %q includes operands for canonical command %q", record.sourcePath.String(), record.targetPath.String(), trace.operandOwner.path.String())
|
||||
}
|
||||
return fmt.Errorf("command alias %q target %q does not resolve to a canonical command or group", record.sourcePath.String(), record.targetPath.String())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func traceCanonicalPath(roots map[string]*canonicalNode, path commandpath.Path) commandAliasTrace {
|
||||
var current *canonicalNode
|
||||
children := roots
|
||||
for index, component := range path {
|
||||
next := children[component]
|
||||
if next != nil {
|
||||
current = next
|
||||
children = current.children
|
||||
continue
|
||||
}
|
||||
operandCount := len(path) - index
|
||||
if current != nil && acceptsOperandCount(current.command, operandCount) {
|
||||
return commandAliasTrace{operandOwner: current}
|
||||
}
|
||||
return commandAliasTrace{}
|
||||
}
|
||||
return commandAliasTrace{exact: current}
|
||||
}
|
||||
|
||||
func acceptsOperandCount(command Command, count int) bool {
|
||||
if command.record == nil || command.record.payload == nil || !command.record.payload.hasRun() {
|
||||
return false
|
||||
}
|
||||
arguments := command.record.payload.bridgeArguments()
|
||||
minimum, maximum := 0, 0
|
||||
for _, argument := range arguments {
|
||||
minimum += argument.Min
|
||||
if maximum >= 0 {
|
||||
if argument.Max < 0 {
|
||||
maximum = -1
|
||||
} else {
|
||||
maximum += argument.Max
|
||||
}
|
||||
}
|
||||
}
|
||||
return count >= minimum && (maximum < 0 || count <= maximum)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user