Resolve entered command routes to canonical paths
This commit is contained in:
8
app.go
8
app.go
@@ -20,8 +20,12 @@ type App struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type routingSnapshot struct {
|
type routingSnapshot struct {
|
||||||
metadata *commandmeta.Command
|
metadata *commandmeta.Command
|
||||||
runners map[commandmeta.Key]commandPayload
|
runners map[commandmeta.Key]commandPayload
|
||||||
|
aliases *alias.Resolver
|
||||||
|
canonical map[string]*canonicalNode
|
||||||
|
flagAliases map[string]resolvedFlagAliases
|
||||||
|
discoveryFlags map[string]resolvedFlagAliases
|
||||||
}
|
}
|
||||||
|
|
||||||
type commandNode struct {
|
type commandNode struct {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Application-owned aliases implementation plan
|
# Application-owned aliases implementation plan
|
||||||
|
|
||||||
Status: in progress; Stages 1–6 completed.
|
Status: in progress; Stages 1–7 completed.
|
||||||
|
|
||||||
## Authority, baseline, and scope
|
## Authority, baseline, and scope
|
||||||
|
|
||||||
@@ -439,6 +439,13 @@ local workspace.
|
|||||||
an explicit longer route while canonical `run full` remains unchanged;
|
an explicit longer route while canonical `run full` remains unchanged;
|
||||||
flags/values/space-containing argv tokens never becoming command components.
|
flags/values/space-containing argv tokens never becoming command components.
|
||||||
|
|
||||||
|
**Completed:** Routing snapshots now retain canonical path metadata and the
|
||||||
|
separate command-alias resolver. Execution resolves command prefixes before
|
||||||
|
parsing, tracks the original input-token origins through alias chains, and
|
||||||
|
preserves the remaining argv. Canonical operands, flags, terminators, unknown
|
||||||
|
paths, and alias-only discovery prefixes receive distinct route results.
|
||||||
|
Verified with root and both integration module suites using the local workspace.
|
||||||
|
|
||||||
## Stage 8: Integrate parser-aware flag aliasing and short grammar
|
## Stage 8: Integrate parser-aware flag aliasing and short grammar
|
||||||
|
|
||||||
**Work**
|
**Work**
|
||||||
|
|||||||
@@ -23,7 +23,8 @@ func (a *App) Execute(ctx context.Context, args []string, streams Streams) Outco
|
|||||||
streams = normalizeStreams(streams)
|
streams = normalizeStreams(streams)
|
||||||
current := newExecution(a, ctx, streams)
|
current := newExecution(a, ctx, streams)
|
||||||
routedSnapshot := current.snapshot
|
routedSnapshot := current.snapshot
|
||||||
parsed := current.parse(args)
|
current.route = resolveEnteredRoute(args, routedSnapshot)
|
||||||
|
parsed := current.parse(current.route.args)
|
||||||
if parsed.Failure != nil {
|
if parsed.Failure != nil {
|
||||||
return current.present(Usage(errors.New(parsed.Failure.Message)), parsed.Path)
|
return current.present(Usage(errors.New(parsed.Failure.Message)), parsed.Path)
|
||||||
}
|
}
|
||||||
@@ -51,6 +52,7 @@ type execution struct {
|
|||||||
options commandmeta.ParseOptions
|
options commandmeta.ParseOptions
|
||||||
snapshot *routingSnapshot
|
snapshot *routingSnapshot
|
||||||
session *internalcobra.Session
|
session *internalcobra.Session
|
||||||
|
route routeResult
|
||||||
}
|
}
|
||||||
|
|
||||||
type commandExecution struct {
|
type commandExecution struct {
|
||||||
|
|||||||
28
internal/alias/provenance_test.go
Normal file
28
internal/alias/provenance_test.go
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
package alias
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/CommandKit/internal/commandpath"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestResolveWithProvenanceCarriesInputOriginsThroughChains(t *testing.T) {
|
||||||
|
resolver, err := NewResolver([]Mapping{
|
||||||
|
{Source: commandpath.Path{"collections"}, Target: commandpath.Path{"list", "collections"}},
|
||||||
|
{Source: commandpath.Path{"list", "collections"}, Target: commandpath.Path{"promptkit", "list", "collections"}},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
path := commandpath.Path{"collections", "--format", "yaml"}
|
||||||
|
gotPath, gotOrigins := resolver.ResolveWithProvenance(path)
|
||||||
|
if want := (commandpath.Path{"promptkit", "list", "collections", "--format", "yaml"}); !reflect.DeepEqual(gotPath, want) {
|
||||||
|
t.Fatalf("resolved path = %v, want %v", gotPath, want)
|
||||||
|
}
|
||||||
|
wantOrigins := [][]int{{0}, {0}, {0}, {1}, {2}}
|
||||||
|
if !reflect.DeepEqual(gotOrigins, wantOrigins) {
|
||||||
|
t.Fatalf("origins = %v, want %v", gotOrigins, wantOrigins)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -132,3 +132,39 @@ func (resolver *Resolver) Resolve(path commandpath.Path) commandpath.Path {
|
|||||||
current = next
|
current = next
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ResolveWithProvenance resolves a command path and carries the input token
|
||||||
|
// indexes that contributed to each output component. Rewritten target
|
||||||
|
// components inherit the origins of the matched source prefix.
|
||||||
|
func (resolver *Resolver) ResolveWithProvenance(path commandpath.Path) (commandpath.Path, [][]int) {
|
||||||
|
current := path.Clone()
|
||||||
|
origins := make([][]int, len(path))
|
||||||
|
for index := range path {
|
||||||
|
origins[index] = []int{index}
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
next, mapping, matched := resolver.ResolveOnce(current)
|
||||||
|
if !matched {
|
||||||
|
return current, origins
|
||||||
|
}
|
||||||
|
|
||||||
|
matchedOrigins := make([]int, 0)
|
||||||
|
seen := make(map[int]struct{})
|
||||||
|
for _, componentOrigins := range origins[:len(mapping.Source)] {
|
||||||
|
for _, origin := range componentOrigins {
|
||||||
|
if _, exists := seen[origin]; exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[origin] = struct{}{}
|
||||||
|
matchedOrigins = append(matchedOrigins, origin)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
nextOrigins := make([][]int, 0, len(mapping.Target)+len(origins)-len(mapping.Source))
|
||||||
|
for range mapping.Target {
|
||||||
|
nextOrigins = append(nextOrigins, append([]int(nil), matchedOrigins...))
|
||||||
|
}
|
||||||
|
nextOrigins = append(nextOrigins, origins[len(mapping.Source):]...)
|
||||||
|
current = next
|
||||||
|
origins = nextOrigins
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -26,7 +26,14 @@ func (a *App) routeSnapshot() *routingSnapshot {
|
|||||||
|
|
||||||
func (a *App) buildRoutingSnapshot() *routingSnapshot {
|
func (a *App) buildRoutingSnapshot() *routingSnapshot {
|
||||||
root := &commandmeta.Command{Name: a.identity.Name}
|
root := &commandmeta.Command{Name: a.identity.Name}
|
||||||
snapshot := &routingSnapshot{metadata: root, runners: make(map[commandmeta.Key]commandPayload)}
|
snapshot := &routingSnapshot{
|
||||||
|
metadata: root,
|
||||||
|
runners: make(map[commandmeta.Key]commandPayload),
|
||||||
|
aliases: a.aliases,
|
||||||
|
canonical: buildCanonicalTree(a.commands),
|
||||||
|
flagAliases: a.flagAliases,
|
||||||
|
discoveryFlags: a.discoveryFlags,
|
||||||
|
}
|
||||||
names := make([]string, 0, len(a.commands))
|
names := make([]string, 0, len(a.commands))
|
||||||
for name := range a.commands {
|
for name := range a.commands {
|
||||||
names = append(names, name)
|
names = append(names, name)
|
||||||
|
|||||||
96
route.go
Normal file
96
route.go
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
package commandkit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/CommandKit/internal/commandpath"
|
||||||
|
)
|
||||||
|
|
||||||
|
type routeKind uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
routeUnknown routeKind = iota
|
||||||
|
routeCanonical
|
||||||
|
routeDiscovery
|
||||||
|
)
|
||||||
|
|
||||||
|
type routeResult struct {
|
||||||
|
kind routeKind
|
||||||
|
args []string
|
||||||
|
enteredPath commandpath.Path
|
||||||
|
canonicalPath commandpath.Path
|
||||||
|
discoveryPath commandpath.Path
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveEnteredRoute(args []string, snapshot *routingSnapshot) routeResult {
|
||||||
|
path, trailing := commandPathTokens(args)
|
||||||
|
result := routeResult{kind: routeUnknown}
|
||||||
|
if snapshot == nil {
|
||||||
|
result.args = append(append([]string(nil), path...), trailing...)
|
||||||
|
result.enteredPath = path.Clone()
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
resolved, origins := snapshot.aliases.ResolveWithProvenance(path)
|
||||||
|
trace := traceCanonicalPath(snapshot.canonical, resolved)
|
||||||
|
switch {
|
||||||
|
case trace.exact != nil:
|
||||||
|
result.kind = routeCanonical
|
||||||
|
result.canonicalPath = trace.exact.path.Clone()
|
||||||
|
case trace.operandOwner != nil:
|
||||||
|
result.kind = routeCanonical
|
||||||
|
result.canonicalPath = trace.operandOwner.path.Clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.kind == routeCanonical {
|
||||||
|
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 {
|
||||||
|
result.kind = routeDiscovery
|
||||||
|
result.discoveryPath = discovery
|
||||||
|
result.enteredPath = discovery.Clone()
|
||||||
|
} 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...)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func commandPathTokens(args []string) (commandpath.Path, []string) {
|
||||||
|
end := len(args)
|
||||||
|
for index, token := range args {
|
||||||
|
if token == "--" || (token != "-" && strings.HasPrefix(token, "-")) {
|
||||||
|
end = index
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
path := make(commandpath.Path, end)
|
||||||
|
copy(path, args[:end])
|
||||||
|
return path, append([]string(nil), args[end:]...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func enteredEndpoint(path commandpath.Path, origins [][]int, canonicalLength int) commandpath.Path {
|
||||||
|
inputLength := 0
|
||||||
|
for _, componentOrigins := range origins[:canonicalLength] {
|
||||||
|
for _, origin := range componentOrigins {
|
||||||
|
if origin+1 > inputLength {
|
||||||
|
inputLength = origin + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return path[:inputLength].Clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
func findDiscoveryPath(contexts map[string]resolvedFlagAliases, path commandpath.Path) (commandpath.Path, bool) {
|
||||||
|
for length := len(path); length > 0; length-- {
|
||||||
|
prefix := path[:length]
|
||||||
|
if _, exists := contexts[prefix.Key()]; exists {
|
||||||
|
return prefix.Clone(), true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
214
route_test.go
Normal file
214
route_test.go
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
package commandkit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func routeTestApp(t *testing.T, extra ...Declaration) *App {
|
||||||
|
t.Helper()
|
||||||
|
collections := simpleRunnable("collections")
|
||||||
|
list := Group("list", "List", collections)
|
||||||
|
testCommand := runnableWithOperands("test", 0, 1)
|
||||||
|
promptkit := Group("promptkit", "PromptKit", list, testCommand)
|
||||||
|
run := runnableWithOperands("run", 0, 1)
|
||||||
|
inspect := runnableWithOperands("inspect", 1, 1)
|
||||||
|
declarations := []Declaration{
|
||||||
|
promptkit,
|
||||||
|
run,
|
||||||
|
inspect,
|
||||||
|
Alias("collections", "list collections"),
|
||||||
|
Alias("list collections", "promptkit list collections"),
|
||||||
|
Alias("pk", "promptkit"),
|
||||||
|
Alias("promptkit list bundles", "promptkit list collections"),
|
||||||
|
Alias("smoke", "promptkit test"),
|
||||||
|
Alias("r", "run"),
|
||||||
|
Alias("r full", "inspect"),
|
||||||
|
}
|
||||||
|
declarations = append(declarations, extra...)
|
||||||
|
app, err := New(Identity{Name: "Example"}, declarations...)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return app
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnteredRoutesResolveChainsAndPreserveTokenOrigins(t *testing.T) {
|
||||||
|
app := routeTestApp(t)
|
||||||
|
snapshot := app.routeSnapshot()
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
args []string
|
||||||
|
entered string
|
||||||
|
canonical string
|
||||||
|
wantArgs []string
|
||||||
|
wantKind routeKind
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "command alias chain",
|
||||||
|
args: []string{"collections", "--tag", "first"},
|
||||||
|
entered: "collections",
|
||||||
|
canonical: "promptkit list collections",
|
||||||
|
wantArgs: []string{"promptkit", "list", "collections", "--tag", "first"},
|
||||||
|
wantKind: routeCanonical,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "group alias composition",
|
||||||
|
args: []string{"pk", "list", "bundles"},
|
||||||
|
entered: "pk list bundles",
|
||||||
|
canonical: "promptkit list collections",
|
||||||
|
wantArgs: []string{"promptkit", "list", "collections"},
|
||||||
|
wantKind: routeCanonical,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "smoke operand",
|
||||||
|
args: []string{"smoke", "all"},
|
||||||
|
entered: "smoke",
|
||||||
|
canonical: "promptkit test",
|
||||||
|
wantArgs: []string{"promptkit", "test", "all"},
|
||||||
|
wantKind: routeCanonical,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "explicit longer alternate spelling",
|
||||||
|
args: []string{"r", "full"},
|
||||||
|
entered: "r full",
|
||||||
|
canonical: "inspect",
|
||||||
|
wantArgs: []string{"inspect"},
|
||||||
|
wantKind: routeCanonical,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "canonical runnable operand",
|
||||||
|
args: []string{"run", "full"},
|
||||||
|
entered: "run",
|
||||||
|
canonical: "run",
|
||||||
|
wantArgs: []string{"run", "full"},
|
||||||
|
wantKind: routeCanonical,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "single dash remains an operand",
|
||||||
|
args: []string{"run", "-"},
|
||||||
|
entered: "run",
|
||||||
|
canonical: "run",
|
||||||
|
wantArgs: []string{"run", "-"},
|
||||||
|
wantKind: routeCanonical,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unknown path",
|
||||||
|
args: []string{"missing", "route"},
|
||||||
|
entered: "missing route",
|
||||||
|
wantArgs: []string{"missing", "route"},
|
||||||
|
wantKind: routeUnknown,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "space-containing token is not split",
|
||||||
|
args: []string{"collections list"},
|
||||||
|
entered: "collections list",
|
||||||
|
wantArgs: []string{"collections list"},
|
||||||
|
wantKind: routeUnknown,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "flag values and spaced tokens",
|
||||||
|
args: []string{"collections", "--message", "--hlep", "--text=two words"},
|
||||||
|
entered: "collections",
|
||||||
|
canonical: "promptkit list collections",
|
||||||
|
wantArgs: []string{"promptkit", "list", "collections", "--message", "--hlep", "--text=two words"},
|
||||||
|
wantKind: routeCanonical,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "terminator stops command matching",
|
||||||
|
args: []string{"collections", "--", "r", "full"},
|
||||||
|
entered: "collections",
|
||||||
|
canonical: "promptkit list collections",
|
||||||
|
wantArgs: []string{"promptkit", "list", "collections", "--", "r", "full"},
|
||||||
|
wantKind: routeCanonical,
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
got := resolveEnteredRoute(test.args, snapshot)
|
||||||
|
if got.kind != test.wantKind {
|
||||||
|
t.Fatalf("route kind = %d, want %d", got.kind, test.wantKind)
|
||||||
|
}
|
||||||
|
if got.enteredPath.String() != test.entered {
|
||||||
|
t.Errorf("entered path = %q, want %q", got.enteredPath.String(), test.entered)
|
||||||
|
}
|
||||||
|
if got.canonicalPath.String() != test.canonical {
|
||||||
|
t.Errorf("canonical path = %q, want %q", got.canonicalPath.String(), test.canonical)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(got.args, test.wantArgs) {
|
||||||
|
t.Errorf("routed args = %q, want %q", got.args, test.wantArgs)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLongerRouteSelectionDoesNotFallBackAfterTargetParsingFails(t *testing.T) {
|
||||||
|
app := routeTestApp(t)
|
||||||
|
result := resolveEnteredRoute([]string{"r", "full"}, app.routeSnapshot())
|
||||||
|
if got, want := result.canonicalPath.String(), "inspect"; got != want {
|
||||||
|
t.Fatalf("canonical path = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
var diagnostics bytes.Buffer
|
||||||
|
outcome := app.Execute(context.Background(), []string{"r", "full"}, Streams{Out: io.Discard, Err: &diagnostics})
|
||||||
|
if outcome.Kind != OutcomeUsage {
|
||||||
|
t.Fatalf("outcome kind = %v, want usage; diagnostics: %s", outcome.Kind, diagnostics.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnteredAliasCanSelectExplicitLongerMapping(t *testing.T) {
|
||||||
|
app := routeTestApp(t, Alias("pk list bundles", "inspect"))
|
||||||
|
result := resolveEnteredRoute([]string{"pk", "list", "bundles"}, app.routeSnapshot())
|
||||||
|
if got, want := result.canonicalPath.String(), "inspect"; got != want {
|
||||||
|
t.Fatalf("canonical path = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
if got, want := result.enteredPath.String(), "pk list bundles"; got != want {
|
||||||
|
t.Fatalf("entered path = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecuteDispatchesEnteredAliasToCanonicalRunner(t *testing.T) {
|
||||||
|
runs := 0
|
||||||
|
command := NewCommand(CommandSpec[struct{}]{
|
||||||
|
Name: "canonical", Summary: "Canonical",
|
||||||
|
Run: func(Invocation, struct{}) Outcome {
|
||||||
|
runs++
|
||||||
|
return Success()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
app, err := New(Identity{Name: "Example"}, command, Alias("shortcut", "canonical"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
outcome := app.Execute(context.Background(), []string{"shortcut"}, Streams{Out: io.Discard, Err: io.Discard})
|
||||||
|
if outcome.Kind != OutcomeSuccess || runs != 1 {
|
||||||
|
t.Fatalf("alias execution = (%v, runs %d), want success and one canonical run", outcome.Kind, runs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAliasOnlyPrefixProducesDiscoveryRoute(t *testing.T) {
|
||||||
|
app, err := New(Identity{Name: "Example"},
|
||||||
|
Group("promptkit", "PromptKit", Group("list", "List", simpleRunnable("collections"))),
|
||||||
|
Alias("list collections", "promptkit list collections"),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := resolveEnteredRoute([]string{"list", "--help"}, app.routeSnapshot())
|
||||||
|
if result.kind != routeDiscovery {
|
||||||
|
t.Fatalf("route kind = %d, want discovery", result.kind)
|
||||||
|
}
|
||||||
|
if got, want := result.discoveryPath.String(), "list"; got != want {
|
||||||
|
t.Fatalf("discovery path = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user