Add shared command path primitives
This commit is contained in:
10
command.go
10
command.go
@@ -3,10 +3,10 @@ package commandkit
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
|
||||||
"unicode"
|
"unicode"
|
||||||
|
|
||||||
commandmeta "gitea.maximumdirect.net/eric/CommandKit/internal/commandmeta"
|
commandmeta "gitea.maximumdirect.net/eric/CommandKit/internal/commandmeta"
|
||||||
|
"gitea.maximumdirect.net/eric/CommandKit/internal/commandpath"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Command is an opaque, reusable declaration captured by NewCommand, Group,
|
// Command is an opaque, reusable declaration captured by NewCommand, Group,
|
||||||
@@ -297,7 +297,7 @@ func collectDeclarationIssues(command Command, parents []string, root bool, issu
|
|||||||
}
|
}
|
||||||
record := command.record
|
record := command.record
|
||||||
path := append(append([]string(nil), parents...), record.name)
|
path := append(append([]string(nil), parents...), record.name)
|
||||||
label := strings.Join(path, " ")
|
label := commandpath.FromTokens(path).String()
|
||||||
for _, issue := range record.issues {
|
for _, issue := range record.issues {
|
||||||
if label != "" {
|
if label != "" {
|
||||||
*issues = append(*issues, fmt.Errorf("command %q: %w", label, issue))
|
*issues = append(*issues, fmt.Errorf("command %q: %w", label, issue))
|
||||||
@@ -320,11 +320,7 @@ func collectDeclarationIssues(command Command, parents []string, root bool, issu
|
|||||||
}
|
}
|
||||||
|
|
||||||
func validateCommandName(name string) error {
|
func validateCommandName(name string) error {
|
||||||
fields := strings.Fields(name)
|
return commandpath.ValidateComponent(name)
|
||||||
if len(fields) != 1 || fields[0] != name || strings.HasPrefix(name, "-") || name == "--" {
|
|
||||||
return fmt.Errorf("command name %q must be one non-option token", name)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateBindings[I any](flags []Flag[I], arguments []Argument[I]) error {
|
func validateBindings[I any](flags []Flag[I], arguments []Argument[I]) error {
|
||||||
|
|||||||
@@ -266,6 +266,11 @@ literal quotes/backslashes, invalid option components, empty declarations,
|
|||||||
root lookup, and a single argv token containing whitespace. Existing routing
|
root lookup, and a single argv token containing whitespace. Existing routing
|
||||||
and help tests remain green.
|
and help tests remain green.
|
||||||
|
|
||||||
|
**Completed:** Added shared command-path parsing, validation, relations, keys,
|
||||||
|
formatting, and token-copy helpers. Core command validation and path diagnostics,
|
||||||
|
plus adapter route comparison and exact help lookup, now use the shared package.
|
||||||
|
Verified with `go test ./internal/commandpath ./internal/cobra .`.
|
||||||
|
|
||||||
## Stage 2: Model deterministic prefix rewriting
|
## Stage 2: Model deterministic prefix rewriting
|
||||||
|
|
||||||
**Work**
|
**Work**
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
commandmeta "gitea.maximumdirect.net/eric/CommandKit/internal/commandmeta"
|
commandmeta "gitea.maximumdirect.net/eric/CommandKit/internal/commandmeta"
|
||||||
|
"gitea.maximumdirect.net/eric/CommandKit/internal/commandpath"
|
||||||
|
|
||||||
spfcobra "github.com/spf13/cobra"
|
spfcobra "github.com/spf13/cobra"
|
||||||
"github.com/spf13/pflag"
|
"github.com/spf13/pflag"
|
||||||
@@ -63,16 +64,17 @@ func (session *Session) Parse(argv []string) commandmeta.Result {
|
|||||||
}
|
}
|
||||||
result.Key = selectedNode.Key
|
result.Key = selectedNode.Key
|
||||||
var pathOK bool
|
var pathOK bool
|
||||||
result.Path, pathOK = pathFor(selected, session.declarations)
|
selectedPath, pathOK := pathFor(selected, session.declarations)
|
||||||
if !pathOK {
|
if !pathOK {
|
||||||
return failure(result, "selected command is not part of the declared tree")
|
return failure(result, "selected command is not part of the declared tree")
|
||||||
}
|
}
|
||||||
|
result.Path = []string(selectedPath)
|
||||||
consumed := len(leading) - len(unconsumed)
|
consumed := len(leading) - len(unconsumed)
|
||||||
if consumed != len(result.Path) || !equalPath(leading[:consumed], result.Path) {
|
if consumed != len(selectedPath) || !commandpath.FromTokens(leading[:consumed]).Equal(selectedPath) {
|
||||||
return failure(result, "command path must match a registered command exactly")
|
return failure(result, "command path must match a registered command exactly")
|
||||||
}
|
}
|
||||||
|
|
||||||
remaining := append(append([]string(nil), unconsumed...), tail...)
|
remaining := append(commandpath.CloneTokens(unconsumed), tail...)
|
||||||
if err := selected.ParseFlags(remaining); err != nil {
|
if err := selected.ParseFlags(remaining); err != nil {
|
||||||
return failure(result, err.Error())
|
return failure(result, err.Error())
|
||||||
}
|
}
|
||||||
@@ -105,12 +107,12 @@ func (session *Session) RenderHelp(path []string) (string, *commandmeta.Failure)
|
|||||||
}
|
}
|
||||||
selected, remaining, err := session.root.Find(path)
|
selected, remaining, err := session.root.Find(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", &commandmeta.Failure{Message: fmt.Sprintf("unknown help path %q", strings.Join(path, " ")), Usage: true}
|
return "", &commandmeta.Failure{Message: fmt.Sprintf("unknown help path %q", commandpath.FromTokens(path).String()), Usage: true}
|
||||||
}
|
}
|
||||||
declaration := session.declarations[selected]
|
declaration := session.declarations[selected]
|
||||||
selectedPath, pathOK := pathFor(selected, session.declarations)
|
selectedPath, pathOK := pathFor(selected, session.declarations)
|
||||||
if declaration == nil || !pathOK || len(remaining) != 0 || !equalPath(path, selectedPath) {
|
if declaration == nil || !pathOK || len(remaining) != 0 || !commandpath.FromTokens(path).Equal(selectedPath) {
|
||||||
return "", &commandmeta.Failure{Message: fmt.Sprintf("unknown help path %q", strings.Join(path, " ")), Usage: true}
|
return "", &commandmeta.Failure{Message: fmt.Sprintf("unknown help path %q", commandpath.FromTokens(path).String()), Usage: true}
|
||||||
}
|
}
|
||||||
buffer := new(bytes.Buffer)
|
buffer := new(bytes.Buffer)
|
||||||
previousOut, previousErr := selected.OutOrStdout(), selected.ErrOrStderr()
|
previousOut, previousErr := selected.OutOrStdout(), selected.ErrOrStderr()
|
||||||
@@ -134,7 +136,7 @@ func (session *Session) UsageLine(path []string) (string, *commandmeta.Failure)
|
|||||||
selected, remaining, err := session.root.Find(path)
|
selected, remaining, err := session.root.Find(path)
|
||||||
declaration := session.declarations[selected]
|
declaration := session.declarations[selected]
|
||||||
selectedPath, pathOK := pathFor(selected, session.declarations)
|
selectedPath, pathOK := pathFor(selected, session.declarations)
|
||||||
if err != nil || declaration == nil || !pathOK || len(remaining) != 0 || !equalPath(path, selectedPath) {
|
if err != nil || declaration == nil || !pathOK || len(remaining) != 0 || !commandpath.FromTokens(path).Equal(selectedPath) {
|
||||||
return "", &commandmeta.Failure{Message: "usage path is not registered"}
|
return "", &commandmeta.Failure{Message: "usage path is not registered"}
|
||||||
}
|
}
|
||||||
line := selected.UseLine()
|
line := selected.UseLine()
|
||||||
@@ -303,28 +305,16 @@ func flagUsage(flag commandmeta.Flag) string {
|
|||||||
return usage
|
return usage
|
||||||
}
|
}
|
||||||
|
|
||||||
func equalPath(left, right []string) bool {
|
|
||||||
if len(left) != len(right) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for index := range left {
|
|
||||||
if left[index] != right[index] {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func splitRoutingPrefix(argv []string) ([]string, []string) {
|
func splitRoutingPrefix(argv []string) ([]string, []string) {
|
||||||
for index, argument := range argv {
|
for index, argument := range argv {
|
||||||
if argument == "--" || (strings.HasPrefix(argument, "-") && argument != "-") {
|
if argument == "--" || (strings.HasPrefix(argument, "-") && argument != "-") {
|
||||||
return append([]string(nil), argv[:index]...), append([]string(nil), argv[index:]...)
|
return commandpath.CloneTokens(argv[:index]), commandpath.CloneTokens(argv[index:])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return append([]string(nil), argv...), nil
|
return commandpath.CloneTokens(argv), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func pathFor(selected *spfcobra.Command, declarations map[*spfcobra.Command]*commandmeta.Command) ([]string, bool) {
|
func pathFor(selected *spfcobra.Command, declarations map[*spfcobra.Command]*commandmeta.Command) (commandpath.Path, bool) {
|
||||||
var reverse []string
|
var reverse []string
|
||||||
for current := selected; current != nil; current = current.Parent() {
|
for current := selected; current != nil; current = current.Parent() {
|
||||||
declaration := declarations[current]
|
declaration := declarations[current]
|
||||||
@@ -338,7 +328,7 @@ func pathFor(selected *spfcobra.Command, declarations map[*spfcobra.Command]*com
|
|||||||
for left, right := 0, len(reverse)-1; left < right; left, right = left+1, right-1 {
|
for left, right := 0, len(reverse)-1; left < right; left, right = left+1, right-1 {
|
||||||
reverse[left], reverse[right] = reverse[right], reverse[left]
|
reverse[left], reverse[right] = reverse[right], reverse[left]
|
||||||
}
|
}
|
||||||
return reverse, true
|
return commandpath.FromTokens(reverse), true
|
||||||
}
|
}
|
||||||
|
|
||||||
func failure(result commandmeta.Result, message string) commandmeta.Result {
|
func failure(result commandmeta.Result, message string) commandmeta.Result {
|
||||||
|
|||||||
106
internal/commandpath/path.go
Normal file
106
internal/commandpath/path.go
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
// Package commandpath provides the shared representation and operations for
|
||||||
|
// command paths used by CommandKit's core and backend adapter.
|
||||||
|
package commandpath
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Path is a sequence of command-name tokens. The empty path identifies the
|
||||||
|
// application root.
|
||||||
|
type Path []string
|
||||||
|
|
||||||
|
// ParseDeclaration parses a whitespace-separated command path for capture.
|
||||||
|
// Declaration paths must contain at least one component.
|
||||||
|
func ParseDeclaration(value string) (Path, error) {
|
||||||
|
path := Path(strings.Fields(value))
|
||||||
|
if len(path) == 0 {
|
||||||
|
return nil, fmt.Errorf("command path %q must contain at least one name", value)
|
||||||
|
}
|
||||||
|
for _, component := range path {
|
||||||
|
if err := ValidateComponent(component); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return path.Clone(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseLookup parses a whitespace-separated lookup path. An empty or
|
||||||
|
// whitespace-only value selects the application root.
|
||||||
|
func ParseLookup(value string) (Path, error) {
|
||||||
|
path := Path(strings.Fields(value))
|
||||||
|
for _, component := range path {
|
||||||
|
if err := ValidateComponent(component); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return path.Clone(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FromTokens makes an owned path copy without splitting or trimming tokens.
|
||||||
|
func FromTokens(tokens []string) Path {
|
||||||
|
return Path(tokens).Clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clone returns an owned copy of the path.
|
||||||
|
func (path Path) Clone() Path {
|
||||||
|
return append(Path(nil), path...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CloneTokens returns an owned copy of token data.
|
||||||
|
func CloneTokens(tokens []string) []string {
|
||||||
|
return append([]string(nil), tokens...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateComponent validates one canonical command-name token.
|
||||||
|
func ValidateComponent(name string) error {
|
||||||
|
fields := strings.Fields(name)
|
||||||
|
if len(fields) != 1 || fields[0] != name || strings.HasPrefix(name, "-") || name == "--" {
|
||||||
|
return fmt.Errorf("command name %q must be one non-option token", name)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Equal reports whether both paths contain the same components.
|
||||||
|
func (path Path) Equal(other Path) bool {
|
||||||
|
if len(path) != len(other) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for index := range path {
|
||||||
|
if path[index] != other[index] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasPrefix reports whether prefix is a component-wise prefix of path.
|
||||||
|
func (path Path) HasPrefix(prefix Path) bool {
|
||||||
|
if len(prefix) > len(path) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for index := range prefix {
|
||||||
|
if path[index] != prefix[index] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Key returns a normalized, unambiguous key for this path.
|
||||||
|
func (path Path) Key() string {
|
||||||
|
var key strings.Builder
|
||||||
|
for _, component := range path {
|
||||||
|
key.WriteString(strconv.Itoa(len(component)))
|
||||||
|
key.WriteByte(':')
|
||||||
|
key.WriteString(component)
|
||||||
|
}
|
||||||
|
return key.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// String formats the path for diagnostics and help text.
|
||||||
|
func (path Path) String() string {
|
||||||
|
return strings.Join(path, " ")
|
||||||
|
}
|
||||||
80
internal/commandpath/path_test.go
Normal file
80
internal/commandpath/path_test.go
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
package commandpath
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestParseDeclarationAndLookup(t *testing.T) {
|
||||||
|
declaration, err := ParseDeclaration(" config\tshow ")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got, want := declaration.String(), "config show"; got != want {
|
||||||
|
t.Fatalf("declaration string = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
if _, err := ParseDeclaration(" \t "); err == nil {
|
||||||
|
t.Fatal("ParseDeclaration accepted an empty path")
|
||||||
|
}
|
||||||
|
|
||||||
|
root, err := ParseLookup(" \t ")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(root) != 0 || root.String() != "" {
|
||||||
|
t.Fatalf("empty lookup path = %#v, string %q", root, root.String())
|
||||||
|
}
|
||||||
|
lookup, err := ParseLookup("config show")
|
||||||
|
if err != nil || !lookup.Equal(declaration) {
|
||||||
|
t.Fatalf("lookup = %#v, err %v; want %#v", lookup, err, declaration)
|
||||||
|
}
|
||||||
|
|
||||||
|
quoted := `say"hello C:\tmp`
|
||||||
|
parsed, err := ParseDeclaration(quoted)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := parsed.String(); got != quoted {
|
||||||
|
t.Fatalf("quoted path = %q, want literal %q", got, quoted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateComponent(t *testing.T) {
|
||||||
|
for _, value := range []string{"--help", "-x", "--", "name word", "name\tpart"} {
|
||||||
|
t.Run(value, func(t *testing.T) {
|
||||||
|
if err := ValidateComponent(value); err == nil {
|
||||||
|
t.Fatalf("ValidateComponent(%q) succeeded", value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if err := ValidateComponent("under_score"); err != nil {
|
||||||
|
t.Fatalf("existing valid component rejected: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPathRelationsAndKey(t *testing.T) {
|
||||||
|
path := Path{"config", "show"}
|
||||||
|
if !path.Equal(Path{"config", "show"}) {
|
||||||
|
t.Fatal("equal paths did not compare equal")
|
||||||
|
}
|
||||||
|
if !path.HasPrefix(Path{"config"}) || !path.HasPrefix(nil) {
|
||||||
|
t.Fatal("path did not recognize component prefix")
|
||||||
|
}
|
||||||
|
if path.HasPrefix(Path{"show"}) || (Path{"config"}).HasPrefix(path) {
|
||||||
|
t.Fatal("path recognized a non-prefix")
|
||||||
|
}
|
||||||
|
if (Path{"a\x00b"}).Key() == (Path{"a", "b"}).Key() {
|
||||||
|
t.Fatal("normalized keys collided for distinct component sequences")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTokenCopiesPreserveTokenContents(t *testing.T) {
|
||||||
|
input := []string{" show ", "", "--", "-x=value"}
|
||||||
|
path := FromTokens(input)
|
||||||
|
input[0] = "changed"
|
||||||
|
if path[0] != " show " {
|
||||||
|
t.Fatalf("FromTokens did not copy input: %#v", path)
|
||||||
|
}
|
||||||
|
copy := CloneTokens(path)
|
||||||
|
copy[1] = "changed"
|
||||||
|
if path[1] != "" {
|
||||||
|
t.Fatalf("CloneTokens did not copy input: %#v", path)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user