356 lines
11 KiB
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
|
|
}
|