diff --git a/app.go b/app.go index d806652..6b4b99f 100644 --- a/app.go +++ b/app.go @@ -24,6 +24,7 @@ type routingSnapshot struct { runners map[commandmeta.Key]commandPayload aliases *alias.Resolver canonical map[string]*canonicalNode + commandAliases []commandmeta.CommandAlias flagAliases map[string]resolvedFlagAliases discoveryFlags map[string]resolvedFlagAliases } diff --git a/builtins.go b/builtins.go index 94edcd3..2f4fbe6 100644 --- a/builtins.go +++ b/builtins.go @@ -89,11 +89,28 @@ func (a *App) renderHelpTargets(targets []string, streams Streams) Outcome { } func (a *App) renderHelpTargetsWithSession(targets []string, streams Streams, session *internalcobra.Session) Outcome { - return a.renderHelpOutcomeWithSession(targets, streams, session) + snapshot := a.routeSnapshot() + route := resolveEnteredRoute(targets, snapshot) + if route.kind == routeUnknown { + return a.renderHelpOutcomeWithSession(targets, streams, session) + } + options := commandmeta.ParseOptions{} + switch route.kind { + case routeCanonical: + options.FlagAliases = snapshot.flagAliases[route.canonicalPath.Key()].neutralRecords() + case routeDiscovery: + options.FlagAliases = snapshot.discoveryFlags[route.discoveryPath.Key()].neutralRecords() + } + session = internalcobra.Prepare(snapshot.metadata, options) + return a.renderHelpViewOutcomeWithSession(a.helpView(route, snapshot), streams, session) } func (a *App) renderHelpOutcomeWithSession(path []string, streams Streams, session *internalcobra.Session) Outcome { - content, failure := session.RenderHelp(path) + return a.renderHelpViewOutcomeWithSession(commandmeta.HelpView{EnteredPath: path, CanonicalPath: path}, streams, session) +} + +func (a *App) renderHelpViewOutcomeWithSession(view commandmeta.HelpView, streams Streams, session *internalcobra.Session) Outcome { + content, failure := session.RenderHelpView(view) if failure != nil { if failure.Usage { return Usage(errors.New(failure.Message)) diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 750c3ec..6b5ab3e 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -1,6 +1,6 @@ # Application-owned aliases implementation plan -Status: in progress; Stages 1–9 completed. +Status: in progress; Stages 1–10 completed. ## Authority, baseline, and scope @@ -531,6 +531,15 @@ shortcut help with a more-specific destination override; inactive/scoped flag visibility; no input/state factories or providers called. Preserve existing help output-delivery handling. +**Completed:** Help targets and `--help` routes now use shared command-alias +resolution. Canonical help views render entered usage paths with canonical +target annotations; root and group views list applicable declared aliases and +effective flag aliases. Alias-only prefixes render discovery help through +`help PREFIX` or global help spellings, while bare incomplete prefixes return a +usage failure pointing to help. Verified help selection, scoped/global +visibility, more-specific group overrides, and input/run callback lifetimes +with root and both integration module suites under the local workspace. + ## Stage 11: Preserve entered spellings in errors and delivery outcomes **Work** diff --git a/execute.go b/execute.go index fb19d5a..1980ada 100644 --- a/execute.go +++ b/execute.go @@ -28,6 +28,13 @@ func (a *App) Execute(ctx context.Context, args []string, streams Streams) Outco if parsed.Failure != nil { return current.present(Usage(errors.New(parsed.Failure.Message)), parsed.Path) } + if current.route.kind == routeDiscovery { + if parsed.HelpRequested { + return current.present(current.renderHelp(parsed.Path), clonePath(current.route.enteredPath)) + } + entered := current.route.enteredPath.String() + return current.present(Usage(fmt.Errorf("incomplete command path %q; use %q --help", entered, entered)), clonePath(current.route.enteredPath)) + } payload, ok := routedSnapshot.runners[parsed.Key] if !ok || payload == nil { return current.present(current.renderHelp(parsed.Path), parsed.Path) @@ -139,14 +146,34 @@ func (current *execution) renderHelp(path []string) Outcome { if current.app == nil { return Failure(errors.New("cannot render help on a nil app")) } - return current.app.renderHelpOutcomeWithSession(path, current.streams, current.presentationSession()) + session := current.presentationSession() + view := current.app.helpView(current.route, current.snapshot) + return current.app.renderHelpViewOutcomeWithSession(view, current.streams, session) } func (current *execution) renderHelpTargets(targets []string) Outcome { if current.app == nil { return Failure(errors.New("cannot render help on a nil app")) } - return current.app.renderHelpTargetsWithSession(targets, current.streams, current.presentationSession()) + snapshot := current.app.routeSnapshot() + if snapshot == nil { + return Failure(errors.New("cannot render help without command metadata")) + } + route := resolveEnteredRoute(targets, snapshot) + if route.kind == routeUnknown { + session := internalcobra.Prepare(snapshot.metadata, current.options) + return current.app.renderHelpOutcomeWithSession(targets, current.streams, session) + } + options := current.options + switch route.kind { + case routeCanonical: + options.FlagAliases = snapshot.flagAliases[route.canonicalPath.Key()].neutralRecords() + case routeDiscovery: + options.FlagAliases = snapshot.discoveryFlags[route.discoveryPath.Key()].neutralRecords() + } + session := internalcobra.Prepare(snapshot.metadata, options) + view := current.app.helpView(route, snapshot) + return current.app.renderHelpViewOutcomeWithSession(view, current.streams, session) } func (current *execution) usageLine(path []string) string { diff --git a/help_view.go b/help_view.go new file mode 100644 index 0000000..82a17d5 --- /dev/null +++ b/help_view.go @@ -0,0 +1,98 @@ +package commandkit + +import ( + "sort" + + commandmeta "gitea.maximumdirect.net/eric/CommandKit/internal/commandmeta" + "gitea.maximumdirect.net/eric/CommandKit/internal/commandpath" +) + +func (a *App) helpView(route routeResult, snapshot *routingSnapshot) commandmeta.HelpView { + view := commandmeta.HelpView{ + EnteredPath: clonePath(route.enteredPath), + CanonicalPath: clonePath(route.canonicalPath), + Discovery: route.kind == routeDiscovery, + } + if snapshot == nil { + return view + } + if view.Discovery { + view.CommandAliases = discoveryHelpAliases(snapshot.commandAliases, route.discoveryPath) + view.FlagAliases = snapshot.discoveryFlags[route.discoveryPath.Key()].neutralRecords() + return view + } + view.FlagAliases = snapshot.flagAliases[route.canonicalPath.Key()].neutralRecords() + view.CommandAliases = routeHelpAliases(snapshot, route) + return view +} + +func clonePath(path commandpath.Path) []string { + return append([]string(nil), path...) +} + +func discoveryHelpAliases(aliases []commandmeta.CommandAlias, prefix commandpath.Path) []commandmeta.CommandAlias { + var result []commandmeta.CommandAlias + for _, alias := range aliases { + source := commandpath.FromTokens(alias.Source) + if len(source) <= len(prefix) || !source.HasPrefix(prefix) { + continue + } + result = append(result, commandmeta.CommandAlias{ + Source: clonePath(source[len(prefix):]), + Target: append([]string(nil), alias.Target...), + }) + } + return result +} + +func routeHelpAliases(snapshot *routingSnapshot, route routeResult) []commandmeta.CommandAlias { + if len(route.canonicalPath) == 0 { + return append([]commandmeta.CommandAlias(nil), snapshot.commandAliases...) + } + node := traceCanonicalPath(snapshot.canonical, route.canonicalPath).exact + if node == nil || len(node.children) == 0 { + return nil + } + + entered := route.enteredPath + canonical := route.canonicalPath + bySource := make(map[string]commandmeta.CommandAlias) + add := func(base commandpath.Path, includeOnlyWhenReached bool) { + for _, alias := range snapshot.commandAliases { + source := commandpath.FromTokens(alias.Source) + if len(source) <= len(base) || !source.HasPrefix(base) { + continue + } + suffix := source[len(base):] + if includeOnlyWhenReached { + enteredSource := append(entered.Clone(), suffix...) + if !snapshot.aliases.Resolve(enteredSource).Equal(commandpath.FromTokens(alias.Target)) { + continue + } + } + key := commandpath.FromTokens(suffix).Key() + if _, exists := bySource[key]; exists { + continue + } + bySource[key] = commandmeta.CommandAlias{ + Source: clonePath(suffix), + Target: append([]string(nil), alias.Target...), + } + } + } + add(entered, false) + if !entered.Equal(canonical) { + add(canonical, true) + } + + keys := make([]string, 0, len(bySource)) + for key := range bySource { + keys = append(keys, key) + } + sort.Strings(keys) + result := make([]commandmeta.CommandAlias, 0, len(keys)) + for _, key := range keys { + result = append(result, bySource[key]) + } + return result +} diff --git a/internal/cobra/parse.go b/internal/cobra/parse.go index 13c374a..9d469be 100644 --- a/internal/cobra/parse.go +++ b/internal/cobra/parse.go @@ -142,6 +142,87 @@ func (session *Session) RenderHelp(path []string) (string, *commandmeta.Failure) return buffer.String(), nil } +// RenderHelpView renders canonical command metadata with the selected entered +// path and its application-owned aliases. +func (session *Session) RenderHelpView(view commandmeta.HelpView) (string, *commandmeta.Failure) { + if session == nil || session.root == nil || session.metadata == nil { + return "", &commandmeta.Failure{Message: "command tree is nil"} + } + if view.Discovery { + return renderDiscoveryHelp(session.metadata.Name, view), nil + } + content, failure := session.RenderHelp(view.CanonicalPath) + if failure != nil { + return "", failure + } + if !commandpath.FromTokens(view.EnteredPath).Equal(commandpath.FromTokens(view.CanonicalPath)) { + if len(view.CanonicalPath) > 0 { + entered := displayCommandPath(session.metadata.Name, view.EnteredPath) + canonical := displayCommandPath(session.metadata.Name, view.CanonicalPath) + lines := strings.Split(content, "\n") + adjusted := make([]string, 0, len(lines)+1) + annotated := false + for _, line := range lines { + if strings.HasPrefix(line, " "+canonical) { + line = strings.Replace(line, canonical, entered, 1) + if !annotated { + adjusted = append(adjusted, line, "Alias for: "+displayCommandPath(session.metadata.Name, view.CanonicalPath)) + annotated = true + continue + } + } + adjusted = append(adjusted, line) + } + content = strings.Join(adjusted, "\n") + } + } + return appendHelpAliases(content, view.CommandAliases, view.FlagAliases), nil +} + +func appendHelpAliases(content string, commandAliases []commandmeta.CommandAlias, flagAliases []commandmeta.FlagAlias) string { + var sections []string + if len(commandAliases) > 0 { + lines := []string{"Command Aliases:"} + for _, alias := range commandAliases { + lines = append(lines, " "+strings.Join(alias.Source, " ")+" → "+strings.Join(alias.Target, " ")) + } + sections = append(sections, strings.Join(lines, "\n")) + } + if len(flagAliases) > 0 { + lines := []string{"Flag Aliases:"} + for _, alias := range flagAliases { + lines = append(lines, " "+alias.Source+" → "+alias.Target) + } + sections = append(sections, strings.Join(lines, "\n")) + } + if len(sections) == 0 { + return content + } + return strings.TrimRight(content, "\n") + "\n\n" + strings.Join(sections, "\n\n") + "\n" +} + +func renderDiscoveryHelp(rootName string, view commandmeta.HelpView) string { + lines := []string{"Usage:", " " + displayCommandPath(rootName, view.EnteredPath) + " [command]"} + if len(view.CommandAliases) > 0 { + lines = append(lines, "", "Available Aliases:") + for _, alias := range view.CommandAliases { + lines = append(lines, " "+strings.Join(alias.Source, " ")+" → "+strings.Join(alias.Target, " ")) + } + } + if len(view.FlagAliases) > 0 { + lines = append(lines, "", "Flag Aliases:") + for _, alias := range view.FlagAliases { + lines = append(lines, " "+alias.Source+" → "+alias.Target) + } + } + return strings.Join(lines, "\n") + "\n" +} + +func displayCommandPath(rootName string, path []string) string { + parts := append([]string{rootName}, path...) + return strings.Join(parts, " ") +} + // UsageLine returns Cobra's usage line for an exact path on this session. func (session *Session) UsageLine(path []string) (string, *commandmeta.Failure) { if session == nil || session.root == nil { diff --git a/internal/commandmeta/metadata.go b/internal/commandmeta/metadata.go index 697fae9..bea0dde 100644 --- a/internal/commandmeta/metadata.go +++ b/internal/commandmeta/metadata.go @@ -47,6 +47,21 @@ type FlagAlias struct { Target string } +// CommandAlias describes one explicitly declared command route for help. +type CommandAlias struct { + Source []string + Target []string +} + +// HelpView selects a canonical or alias-only command help presentation. +type HelpView struct { + EnteredPath []string + CanonicalPath []string + Discovery bool + CommandAliases []CommandAlias + FlagAliases []FlagAlias +} + // Failure is a neutral parsing or presentation failure. type Failure struct { Message string diff --git a/parser.go b/parser.go index 9c5c530..418a71e 100644 --- a/parser.go +++ b/parser.go @@ -5,6 +5,7 @@ import ( internalcobra "gitea.maximumdirect.net/eric/CommandKit/internal/cobra" commandmeta "gitea.maximumdirect.net/eric/CommandKit/internal/commandmeta" + "gitea.maximumdirect.net/eric/CommandKit/internal/commandpath" ) func (a *App) parse(args []string, options commandmeta.ParseOptions) commandmeta.Result { @@ -34,6 +35,18 @@ func (a *App) buildRoutingSnapshot() *routingSnapshot { flagAliases: a.flagAliases, discoveryFlags: a.discoveryFlags, } + for _, rule := range a.routingRules { + if rule.record.kind != commandAliasRule { + continue + } + snapshot.commandAliases = append(snapshot.commandAliases, commandmeta.CommandAlias{ + Source: []string(rule.record.sourcePath.Clone()), + Target: []string(snapshot.aliases.Resolve(rule.record.targetPath)), + }) + } + sort.Slice(snapshot.commandAliases, func(i, j int) bool { + return commandpath.FromTokens(snapshot.commandAliases[i].Source).Key() < commandpath.FromTokens(snapshot.commandAliases[j].Source).Key() + }) names := make([]string, 0, len(a.commands)) for name := range a.commands { names = append(names, name) diff --git a/route.go b/route.go index df4b823..71e4b66 100644 --- a/route.go +++ b/route.go @@ -46,17 +46,20 @@ func resolveEnteredRoute(args []string, snapshot *routingSnapshot) routeResult { result.enteredPath = enteredEndpoint(path, origins, len(result.canonicalPath)) } else if len(path) == 0 { result.kind = routeCanonical - } else if discovery, ok := findDiscoveryPath(snapshot.discoveryFlags, path); ok { + } else if discovery, ok := findDiscoveryPath(snapshot.discoveryFlags, path); ok && len(discovery) == len(path) { result.kind = routeDiscovery result.discoveryPath = discovery result.enteredPath = discovery.Clone() + result.args = commandpath.CloneTokens(trailing) } else { result.enteredPath = path.Clone() } - result.args = make([]string, 0, len(resolved)+len(trailing)) - result.args = append(result.args, resolved...) - result.args = append(result.args, trailing...) + if result.kind != routeDiscovery { + result.args = make([]string, 0, len(resolved)+len(trailing)) + result.args = append(result.args, resolved...) + result.args = append(result.args, trailing...) + } return result } diff --git a/route_test.go b/route_test.go index 09c4f8b..0c09556 100644 --- a/route_test.go +++ b/route_test.go @@ -347,7 +347,123 @@ func TestAliasOnlyPrefixProducesDiscoveryRoute(t *testing.T) { if got, want := result.enteredPath.String(), "list"; got != want { t.Fatalf("entered path = %q, want %q", got, want) } - if !reflect.DeepEqual(result.args, []string{"list", "--help"}) { - t.Fatalf("discovery args = %q, want [list --help]", result.args) + if !reflect.DeepEqual(result.args, []string{"--help"}) { + t.Fatalf("discovery args = %q, want [--help]", result.args) + } +} + +func TestAliasHelpTargetsGroupsAndDiscoveryViews(t *testing.T) { + var inputBuilds, promptRuns int + showPrompt := NewCommand(CommandSpec[struct{}]{ + Name: "prompt", + NewInput: func() struct{} { + inputBuilds++ + return struct{}{} + }, + Flags: []Flag[struct{}]{ValueFlag(FlagMeta{Name: "format"}, (*string)(nil), + func(value string) (string, error) { return value, nil }, + func(*struct{}, Supplied[string]) {}, + )}, + Run: func(Invocation, struct{}) Outcome { + promptRuns++ + return Success() + }, + }) + commands := Group("promptkit", "PromptKit", + Group("list", "List", simpleRunnable("collections")), + Group("show", "Show", showPrompt), + ) + app, err := New(Identity{Name: "example"}, commands, + Alias("list collections", "promptkit list collections"), + Alias("promptkit list bundles", "promptkit list collections"), + Alias("pk", "promptkit"), + Alias("pk list bundles", "promptkit show prompt"), + FlagAlias("--hlep", "--help"), + FlagAliasFor("promptkit show prompt", "--fmt", "--format"), + ) + if err != nil { + t.Fatal(err) + } + for _, test := range []struct { + name string + args []string + want []string + omit []string + }{ + { + name: "explicit command help resolves entered target", + args: []string{"help", "list", "collections"}, + want: []string{"Usage:\n example list collections", "Alias for: example promptkit list collections"}, + }, + { + name: "aliased help flag uses entered command path", + args: []string{"list", "collections", "--hlep"}, + want: []string{"Usage:\n example list collections", "Alias for: example promptkit list collections", "--hlep → --help"}, + }, + { + name: "canonical group shows direct aliases", + args: []string{"help", "promptkit", "list"}, + want: []string{"Command Aliases:", "bundles → promptkit list collections"}, + }, + { + name: "group shortcut shows more-specific override", + args: []string{"pk", "--help"}, + want: []string{"Usage:\n example pk [flags]", "Alias for: example promptkit", "list bundles → promptkit show prompt"}, + omit: []string{"list bundles → promptkit list collections"}, + }, + { + name: "explicit discovery target", + args: []string{"help", "list"}, + want: []string{"Usage:\n example list [command]", "Available Aliases:", "collections → promptkit list collections", "--hlep → --help"}, + omit: []string{"--fmt"}, + }, + { + name: "discovery help flag", + args: []string{"list", "--hlep"}, + want: []string{"Usage:\n example list [command]", "collections → promptkit list collections"}, + }, + { + name: "scoped flag alias appears on canonical command help", + args: []string{"promptkit", "show", "prompt", "--hlep"}, + want: []string{"--fmt → --format"}, + }, + { + name: "group excludes descendant scoped flag aliases", + args: []string{"promptkit", "--help"}, + want: []string{"Command Aliases:"}, + omit: []string{"--fmt → --format"}, + }, + { + name: "root lists explicit command aliases separately", + args: []string{"--help"}, + want: []string{"Available Commands:", "Command Aliases:", "list collections → promptkit list collections", "pk → promptkit"}, + }, + } { + t.Run(test.name, func(t *testing.T) { + var output bytes.Buffer + outcome := app.Execute(context.Background(), test.args, Streams{Out: &output, Err: io.Discard}) + if outcome.Kind != OutcomeSuccess { + t.Fatalf("Execute(%q) = %#v; output %q", test.args, outcome, output.String()) + } + for _, fragment := range test.want { + if !strings.Contains(output.String(), fragment) { + t.Errorf("help output %q does not contain %q", output.String(), fragment) + } + } + for _, fragment := range test.omit { + if strings.Contains(output.String(), fragment) { + t.Errorf("help output %q unexpectedly contains %q", output.String(), fragment) + } + } + }) + } + + var diagnostics bytes.Buffer + outcome := app.Execute(context.Background(), []string{"list"}, Streams{Out: io.Discard, Err: &diagnostics}) + if outcome.Kind != OutcomeUsage || !strings.Contains(outcome.Cause.Error(), "--help") { + t.Fatalf("bare discovery prefix = %#v, diagnostics %q; want usage pointing to list --help", outcome, diagnostics.String()) + } + if inputBuilds != 0 || promptRuns != 0 { + t.Fatalf("help built %d inputs and ran %d commands", inputBuilds, promptRuns) } }