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 && 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) }