Render help through command aliases

This commit is contained in:
2026-10-19 19:40:55 +00:00
parent 451a0c483f
commit a504efbad7
10 changed files with 391 additions and 11 deletions

1
app.go
View File

@@ -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
}

View File

@@ -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))

View File

@@ -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**

View File

@@ -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 {

98
help_view.go Normal file
View File

@@ -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
}

View File

@@ -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 {

View File

@@ -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

View File

@@ -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)

View File

@@ -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
}

View File

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