package commandkit import ( "errors" "fmt" "strings" "unicode" commandmeta "gitea.maximumdirect.net/eric/CommandKit/internal/commandmeta" "gitea.maximumdirect.net/eric/CommandKit/internal/commandpath" ) // Command is an opaque, reusable declaration of canonical commands and groups. // NewCommand, Group, RootGroup, and integration command factories return // Command values. Capture copies declaration slices but retains callback and // caller-owned object references. Installation validates the declaration // before changing an App registry. type Command struct{ record *commandRecord } // Declaration is a captured canonical command or application routing rule // accepted by New and App.Register. Use []Declaration when collecting both // kinds. Group and RootGroup children remain Commands; install routing rules // alongside the completed command tree through New or Register. Only // CommandKit declaration types implement Declaration. // // A zero Command, zero RoutingRule, nil declaration, or typed nil declaration // pointer is invalid. Use NoCommand to represent an intentionally absent // optional command. type Declaration interface { commandkitDeclaration() } func (Command) commandkitDeclaration() {} // RoutingRule is an opaque, reusable command or flag alias declaration. Alias, // FlagAlias, and FlagAliasFor capture routing rules eagerly and retain any // construction issues for New or App.Register to report. Identical duplicate // mappings are accepted; competing targets for the same source are errors. type RoutingRule struct{ record *routingRuleRecord } func (RoutingRule) commandkitDeclaration() {} type routingRuleKind uint8 const ( commandAliasRule routingRuleKind = iota + 1 globalFlagAliasRule scopedFlagAliasRule ) type capturedFlagSpelling struct { name string short bool } type routingRuleRecord struct { kind routingRuleKind sourcePath commandpath.Path targetPath commandpath.Path scopePath commandpath.Path sourceFlag capturedFlagSpelling targetFlag capturedFlagSpelling sourceText string targetText string scopeText string issues []error } type commandRecord struct { name string summary string description string children []Command payload commandPayload issues []error rootOnly bool absent bool invalid bool } // Supplied carries a value and whether it was explicitly provided. Defaults can // have Set=false; an explicitly supplied empty, false, or zero value has Set=true. type Supplied[T any] struct { // Value is the associated value. Value T // Set reports whether a caller explicitly supplied the value. Set bool } // FlagMeta describes a command flag. Required applies when execution prepares // the selected command; help reports the metadata without requiring the value. type FlagMeta struct { // Name is the flag name without leading dashes. Name string // Short is an optional one-byte spelling. Short string // Usage briefly describes the flag. Usage string // Required reports whether execution requires the flag to be supplied. Required bool } // ArgumentMeta describes a positional command argument. Its name and usage are // used in generated help. type ArgumentMeta struct { // Name is the argument name shown in command help. Name string // Usage briefly describes the argument. Usage string } // Flag represents a typed binding for a command flag. Copies retain binding // identity; separately constructed flags are distinct even when their metadata // and callbacks are equivalent. type Flag[I any] struct { binding flagBinding[I] } type bindingIdentity struct{ marker byte } type preparedValue[I any] struct { identity *bindingIdentity supplied bool apply func(*I) } type flagBinding[I any] struct { identity *bindingIdentity meta FlagMeta deferredRequired bool defaultText string repeated bool boolean bool prepare func([]string) (preparedValue[I], error) valid bool } // Argument represents a typed binding for a positional command argument. type Argument[I any] struct { binding argumentBinding[I] } type argumentBinding[I any] struct { identity *bindingIdentity meta ArgumentMeta min int max int prepare func([]string) (preparedValue[I], error) valid bool } // CommandSpec declares one command using typed input and handler callbacks. // Capture copies its Flags, Arguments, and Children slices. NewInput runs only // when execution reaches input construction; a nil factory yields a fresh zero // value. CheckInput runs after supplied values are converted and applied, before // Run. Run may be nil for a parent that only groups child commands. type CommandSpec[I any] struct { // Name is one command path component. Name string // Summary is a short description of the command. Summary string // Description provides additional command detail. Description string // Children are relative child declarations captured with this command. Children []Command // NewInput creates an input value for execution. A nil factory uses a fresh // zero value. NewInput func() I // Flags declares the command's local flags. Flags []Flag[I] // Arguments declares the command's positional inputs. Arguments []Argument[I] // CheckInput validates declared combinations of parsed inputs. CheckInput func(I) error // Run handles an invocation. It may be nil for a command used as a group. Run func(Invocation, I) Outcome } type commandPayload interface { validate() error hasRun() bool bridgeFlags() []commandmeta.Flag bridgeArguments() []commandmeta.Argument prepare([][]string, [][]string, []string, bool) (func(*execution) Outcome, error) } type typedCommand[I any] struct { spec CommandSpec[I] privateRun func(commandExecution, I) Outcome } func (c typedCommand[I]) validate() error { return validateBindings(c.spec.Flags, c.spec.Arguments) } func (c typedCommand[I]) hasRun() bool { return c.spec.Run != nil || c.privateRun != nil } func (c typedCommand[I]) bridgeFlags() []commandmeta.Flag { flags := make([]commandmeta.Flag, len(c.spec.Flags)) for index, flag := range c.spec.Flags { binding := flag.binding flags[index] = commandmeta.Flag{ Name: binding.meta.Name, Short: binding.meta.Short, Usage: binding.meta.Usage, Default: binding.defaultText, Boolean: binding.boolean, Repeated: binding.repeated, Required: binding.meta.Required, } } return flags } func (c typedCommand[I]) bridgeArguments() []commandmeta.Argument { arguments := make([]commandmeta.Argument, len(c.spec.Arguments)) for index, argument := range c.spec.Arguments { binding := argument.binding arguments[index] = commandmeta.Argument{ Name: binding.meta.Name, Usage: binding.meta.Usage, Min: binding.min, Max: binding.max, } } return arguments } func (c typedCommand[I]) prepare(flagValues, flagSpellings [][]string, operands []string, enforceRequired bool) (func(*execution) Outcome, error) { prepared, err := prepareInputWithSpellings(c.spec, flagValues, flagSpellings, operands, enforceRequired) if err != nil { return nil, err } return func(current *execution) Outcome { input := prepared.build() if c.spec.CheckInput != nil { if err := c.spec.CheckInput(input); err != nil { return classifyInputError(err) } } if c.privateRun != nil { return c.privateRun(current.commandContext(), input) } return c.spec.Run(current.invocation(), input) }, nil } // NewCommand captures a typed canonical command declaration without // constructing its input or running callbacks. It copies the spec's flags, // arguments, and children slices. Construction issues are retained and // reported when the declaration is installed; NewCommand itself does not // return an error. The returned declaration can be reused across Apps. func NewCommand[I any](spec CommandSpec[I]) Command { return captureCommand(spec, nil) } func captureCommand[I any](spec CommandSpec[I], privateRun func(commandExecution, I) Outcome) Command { copySpec := spec copySpec.Flags = append([]Flag[I](nil), spec.Flags...) copySpec.Arguments = append([]Argument[I](nil), spec.Arguments...) copySpec.Children = append([]Command(nil), spec.Children...) payload := typedCommand[I]{spec: copySpec, privateRun: privateRun} record := &commandRecord{ name: spec.Name, summary: spec.Summary, description: spec.Description, children: copySpec.Children, payload: payload, } if err := payload.validate(); err != nil { record.issues = append(record.issues, fmt.Errorf("command %q: %w", spec.Name, err)) } captureComposition(record) return Command{record: record} } // Group captures a non-runnable canonical command group and a copy of its // relative Command child slice. Child declarations remain reusable and are not // recursively copied. Routing rules cannot be children; install them alongside // the root command tree through New or App.Register. func Group(name, summary string, children ...Command) Command { record := &commandRecord{name: name, summary: summary, children: append([]Command(nil), children...)} captureComposition(record) return Command{record: record} } // RootGroup captures a group that may only be installed at the application // root. Installation rejects it below any other command, including through // wrappers. Its children are canonical Commands; install routing rules at the // application root separately. func RootGroup(name, summary string, children ...Command) Command { command := Group(name, summary, children...) command.record.rootOnly = true return command } // WithError returns a new Command declaration that also retains a non-nil err // for installation. Earlier declaration and child errors remain available, // and wrapped error identity is preserved. A nil err returns command unchanged. func WithError(command Command, err error) Command { if err == nil { return command } var record commandRecord if command.record == nil { record.invalid = true } else { record = *command.record record.issues = append([]error(nil), record.issues...) } record.issues = append(record.issues, err) return Command{record: &record} } // NoCommand explicitly represents an absent optional command declaration. // Installation skips it, but reports errors attached to it; a zero Command is // invalid and is not an absence marker. Routing rules have no absence marker. func NoCommand() Command { return Command{record: &commandRecord{absent: true}} } // Alias captures a command-path alias from path to target. Paths are // whitespace-separated sequences of command names relative to the application // root. The target must resolve to a canonical command or group; aliases do not // add operands or transform flag values. Construction issues are retained for // New or App.Register to report. Identical mappings are accepted, while // different targets for the same source path are installation errors. func Alias(path, target string) RoutingRule { record := &routingRuleRecord{ kind: commandAliasRule, sourceText: path, targetText: target, } record.sourcePath = captureRoutingPath("source", path, &record.issues) record.targetPath = captureRoutingPath("target", target, &record.issues) return RoutingRule{record: record} } // FlagAlias captures an application-wide flag spelling alias. Spellings must // be one long flag name or one individual short spelling. The mapping applies // wherever its canonical target flag is available and contributes nothing on // commands without that flag. Identical mappings are accepted; different // targets for the same source spelling in this scope are installation errors. func FlagAlias(spelling, target string) RoutingRule { record := &routingRuleRecord{ kind: globalFlagAliasRule, sourceText: spelling, targetText: target, } record.sourceFlag = captureFlagSpelling("source", spelling, &record.issues) record.targetFlag = captureFlagSpelling("target", target, &record.issues) return RoutingRule{record: record} } // FlagAliasFor captures a flag spelling alias scoped to a canonical command // path, not an alias path. The command and canonical target flag must be // available in the complete installation. Scoped mappings take precedence over // application-wide mappings with the same source spelling. Identical mappings // are accepted; competing targets in the same scope are installation errors. func FlagAliasFor(commandPath, spelling, target string) RoutingRule { record := &routingRuleRecord{ kind: scopedFlagAliasRule, sourceText: spelling, targetText: target, scopeText: commandPath, } record.scopePath = captureRoutingPath("command scope", commandPath, &record.issues) record.sourceFlag = captureFlagSpelling("source", spelling, &record.issues) record.targetFlag = captureFlagSpelling("target", target, &record.issues) return RoutingRule{record: record} } func captureRoutingPath(label, value string, issues *[]error) commandpath.Path { path, err := commandpath.ParseDeclaration(value) if err != nil { *issues = append(*issues, fmt.Errorf("invalid %s path: %w", label, err)) } return path } func captureFlagSpelling(label, value string, issues *[]error) capturedFlagSpelling { spelling, err := parseFlagSpelling(value) if err != nil { *issues = append(*issues, fmt.Errorf("invalid %s flag spelling %q: %w", label, value, err)) } return spelling } func parseFlagSpelling(value string) (capturedFlagSpelling, error) { if strings.HasPrefix(value, "--") { name := value[2:] if validFlagName(name) { return capturedFlagSpelling{name: name}, nil } } if len(value) == 2 && value[0] == '-' && value[1] != '-' { return capturedFlagSpelling{name: value[1:], short: true}, nil } return capturedFlagSpelling{}, fmt.Errorf("must be a long flag or one-byte short flag spelling") } func captureComposition(record *commandRecord) { if err := validateCommandName(record.name); err != nil { record.issues = append(record.issues, err) } seen := make(map[string]struct{}, len(record.children)) for _, child := range record.children { if child.record == nil { record.issues = append(record.issues, fmt.Errorf("command %q has an invalid child declaration", record.name)) continue } if child.record.absent { continue } name := child.record.name if _, exists := seen[name]; exists { record.issues = append(record.issues, fmt.Errorf("duplicate child command %q under %q", name, record.name)) } seen[name] = struct{}{} } } func validateCommandDeclaration(command Command) error { return validateCommandPlacement(command) } func validateCommandPlacement(command Command) error { var issues []error collectDeclarationIssues(command, nil, true, &issues) return errors.Join(issues...) } func collectDeclarationIssues(command Command, parents []string, root bool, issues *[]error) { if command.record == nil { *issues = append(*issues, errors.New("invalid zero command declaration")) return } record := command.record path := append(append([]string(nil), parents...), record.name) label := commandpath.FromTokens(path).String() for _, issue := range record.issues { if label != "" { *issues = append(*issues, fmt.Errorf("command %q: %w", label, issue)) } else { *issues = append(*issues, issue) } } if record.rootOnly && !root { *issues = append(*issues, fmt.Errorf("root-only command %q is nested beneath another command", label)) } if record.invalid || record.absent { return } for _, child := range record.children { if child.record == nil { continue } collectDeclarationIssues(child, path, false, issues) } } func validateCommandName(name string) error { return commandpath.ValidateComponent(name) } func validateBindings[I any](flags []Flag[I], arguments []Argument[I]) error { flagNames := make(map[string]struct{}, len(flags)) shortNames := make(map[string]struct{}, len(flags)) for _, flag := range flags { binding := flag.binding if !validFlagName(binding.meta.Name) { return fmt.Errorf("flag name %q must use lowercase hyphen-separated words", binding.meta.Name) } if binding.meta.Name == "help" { return fmt.Errorf("flag name %q is reserved for help", binding.meta.Name) } if commandmeta.IsReportingFlag(binding.meta.Name) { return fmt.Errorf("flag name %q is reserved for reporting", binding.meta.Name) } if _, exists := flagNames[binding.meta.Name]; exists { return fmt.Errorf("duplicate flag name %q", binding.meta.Name) } flagNames[binding.meta.Name] = struct{}{} if binding.meta.Short != "" { if len(binding.meta.Short) != 1 || binding.meta.Short == "-" { return fmt.Errorf("short spelling %q must be one byte", binding.meta.Short) } if binding.meta.Short == "h" { return fmt.Errorf("short spelling %q is reserved for help", binding.meta.Short) } if _, exists := shortNames[binding.meta.Short]; exists { return fmt.Errorf("duplicate short spelling %q", binding.meta.Short) } shortNames[binding.meta.Short] = struct{}{} } if binding.identity == nil || !binding.valid || binding.prepare == nil { return fmt.Errorf("flag %q is missing parser or setter wiring", binding.meta.Name) } } for _, argument := range arguments { binding := argument.binding if binding.meta.Name == "" { return fmt.Errorf("argument name must not be empty") } if binding.min < 0 || binding.max < -1 || (binding.max >= 0 && binding.min > binding.max) { return fmt.Errorf("argument %q has invalid bounds %d..%d", binding.meta.Name, binding.min, binding.max) } if binding.identity == nil || !binding.valid || binding.prepare == nil { return fmt.Errorf("argument %q is missing parser or setter wiring", binding.meta.Name) } } return nil } func validFlagName(name string) bool { if name == "" || name[0] == '-' || name[len(name)-1] == '-' { return false } segmentHasCharacter := false for _, character := range name { if character == '-' { if !segmentHasCharacter { return false } segmentHasCharacter = false continue } if !unicode.IsLower(character) && !unicode.IsDigit(character) { return false } segmentHasCharacter = true } return segmentHasCharacter }