Files
commandkit/registry.go

356 lines
11 KiB
Go

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 canonical commands and routing rules to the
// application. The complete batch is validated before registry mutation; on
// error the registry and its derived command metadata remain unchanged. A
// successful nonempty batch becomes visible to later invocations. Declarations
// remain reusable. Identical duplicate routing mappings are accepted, while
// competing targets for the same source are errors. Nil and zero declarations
// are invalid; use NoCommand for an intentionally absent optional command.
func (a *App) Register(declarations ...Declaration) error {
return a.installBatch(declarations...)
}
type installationCandidate struct {
commands map[string]*commandNode
routingRules []RoutingRule
aliases *internalalias.Resolver
flagAliases map[string]resolvedFlagAliases
discoveryFlags map[string]resolvedFlagAliases
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")
}
commands := make([]Command, 0, len(declarations))
rules := make([]RoutingRule, 0)
for _, declaration := range declarations {
switch value := declaration.(type) {
case Command:
commands = append(commands, value)
case *Command:
if value == nil {
return fmt.Errorf("nil *Command declaration")
}
commands = append(commands, *value)
case RoutingRule:
rules = append(rules, value)
case *RoutingRule:
if value == nil {
return fmt.Errorf("nil *RoutingRule declaration")
}
rules = append(rules, *value)
case nil:
return fmt.Errorf("nil declaration")
default:
return fmt.Errorf("unsupported declaration type %T", declaration)
}
}
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.flagAliases = candidate.flagAliases
a.discoveryFlags = candidate.discoveryFlags
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 nil, err
}
if command.record.absent {
continue
}
name := command.record.name
if _, exists := candidate.commands[name]; exists {
return nil, fmt.Errorf("command path %q is already registered", name)
}
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
}
flagAliases, discoveryFlags, err := compileFlagAliases(rules, tree, resolver)
if err != nil {
return nil, err
}
candidate.flagAliases = flagAliases
candidate.discoveryFlags = discoveryFlags
return candidate, nil
}
func normalizeRoutingRules(rules []RoutingRule) ([]RoutingRule, error) {
unique := make(map[routingRuleKey]RoutingRule, len(rules))
commandTargets := make(map[string]string)
type scopedFlagKey struct {
kind routingRuleKind
scope string
source string
}
flagTargets := make(map[scopedFlagKey]string)
for _, rule := range rules {
if rule.record == nil {
return nil, fmt.Errorf("invalid zero routing rule declaration")
}
if len(rule.record.issues) > 0 {
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
} else {
scopeKey := scopedFlagKey{kind: key.kind, scope: key.scope, source: key.source}
if previous, exists := flagTargets[scopeKey]; exists && previous != key.target {
return nil, fmt.Errorf("conflicting flag aliases for %q", rule.record.sourceFlag.token())
}
flagTargets[scopeKey] = 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
}
}
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})
}
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)
}
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 && acceptsOperandPrefix(current.command, operandCount) {
return commandAliasTrace{operandOwner: current}
}
return commandAliasTrace{}
}
return commandAliasTrace{exact: current}
}
// acceptsOperandPrefix reports whether more operands could complete this prefix.
// A source need not already satisfy the command's minimum to intercept it.
func acceptsOperandPrefix(command Command, count int) bool {
if command.record == nil || command.record.payload == nil || !command.record.payload.hasRun() {
return false
}
arguments := command.record.payload.bridgeArguments()
maximum := 0
for _, argument := range arguments {
if maximum >= 0 {
if argument.Max < 0 {
maximum = -1
} else {
maximum += argument.Max
}
}
}
return maximum < 0 || count <= maximum
}