Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions modules/code/insight_test.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -28,6 +28,7 @@ func (noopResolver) ResolveTextFormatter(id string) cmdctx.TextFormatterFn
func (noopResolver) ResolveBodyFn(id string) cmdctx.CreateBodyFn { return nil }
func (noopResolver) ResolveQueryParamsFn(id string) cmdctx.QueryParamsFn { return nil }
func (noopResolver) ResolveFlagResolveFn(id string) cmdctx.FlagResolveFn { return nil }
func (noopResolver) ResolveIdPartDefaultFn(id string) cmdctx.IdPartDefaultFn { return nil }
func (noopResolver) ResolveFetchFn(id string) (cmdctx.FetchFn, error) { return nil, nil }
func (noopResolver) ResolveListTransformFn(id string) cmdctx.ListTransformFn { return nil }
func (noopResolver) ResolveEndpointValidator(id string) cmdctx.EndpointValidatorFn { return nil }
Expand DownExpand Up@@ -375,4 +376,5 @@ func (s *moduleInitSpy) RegisterFetchFn(string, cmdctx.FetchFn)
func (s *moduleInitSpy) RegisterListTransformFn(string, cmdctx.ListTransformFn) {}
func (s *moduleInitSpy) RegisterFlagCompletionFn(string, registry.FlagCompletionFn) {}
func (s *moduleInitSpy) RegisterFlagResolveFn(string, cmdctx.FlagResolveFn) {}
func (s *moduleInitSpy) RegisterIdPartDefaultFn(string, cmdctx.IdPartDefaultFn) {}
func (s *moduleInitSpy) RegisterEndpointValidatorFn(string, cmdctx.EndpointValidatorFn) {}
2 changes: 2 additions & 0 deletions modules/gitops/gitops_test.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -29,6 +29,7 @@ func (noopResolver) ResolveTextFormatter(id string) cmdctx.TextFormatterFn
func (noopResolver) ResolveBodyFn(id string) cmdctx.CreateBodyFn { return nil }
func (noopResolver) ResolveQueryParamsFn(id string) cmdctx.QueryParamsFn { return nil }
func (noopResolver) ResolveFlagResolveFn(id string) cmdctx.FlagResolveFn { return nil }
func (noopResolver) ResolveIdPartDefaultFn(id string) cmdctx.IdPartDefaultFn { return nil }
func (noopResolver) ResolveFetchFn(id string) (cmdctx.FetchFn, error) { return nil, nil }
func (noopResolver) ResolveListTransformFn(id string) cmdctx.ListTransformFn { return nil }
func (noopResolver) ResolveEndpointValidator(id string) cmdctx.EndpointValidatorFn { return nil }
Expand DownExpand Up@@ -382,4 +383,5 @@ func (s *moduleInitSpy) RegisterFetchFn(string, cmdctx.FetchFn)
func (s *moduleInitSpy) RegisterListTransformFn(string, cmdctx.ListTransformFn) {}
func (s *moduleInitSpy) RegisterFlagCompletionFn(string, registry.FlagCompletionFn) {}
func (s *moduleInitSpy) RegisterFlagResolveFn(string, cmdctx.FlagResolveFn) {}
func (s *moduleInitSpy) RegisterIdPartDefaultFn(string, cmdctx.IdPartDefaultFn) {}
func (s *moduleInitSpy) RegisterEndpointValidatorFn(string, cmdctx.EndpointValidatorFn) {}
6 changes: 6 additions & 0 deletions pkg/cmdctx/cmdctx.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -106,13 +106,19 @@ type RawBody struct {
// flag value as an empty string. Returning an error aborts the command.
type FlagResolveFn func(ctx *Ctx, raw string) (string, error)

// IdPartDefaultFn supplies a value for a missing/sentinel id_part, derived from
// ctx and the environment (e.g. the current directory's git remote) rather than
// from user input — see spec.IdPartDefaultFn and spec.IdPartSentinel.
type IdPartDefaultFn func(ctx *Ctx) (string, error)

// Resolver looks up registered handler functions by their fully-qualified ID.
// The registry implements this; commands receive it via Ctx.Resolver.
type Resolver interface {
ResolveTextFormatter(id string) TextFormatterFn
ResolveBodyFn(id string) CreateBodyFn
ResolveQueryParamsFn(id string) QueryParamsFn
ResolveFlagResolveFn(id string) FlagResolveFn
ResolveIdPartDefaultFn(id string) IdPartDefaultFn
ResolveFetchFn(id string) (FetchFn, error)
ResolveListTransformFn(id string) ListTransformFn
ResolveEndpointValidator(id string) EndpointValidatorFn
Expand Down
72 changes: 62 additions & 10 deletions pkg/registry/buildctx.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -228,7 +228,7 @@ func buildCtx(cmd *cobra.Command, cs *spec.CommandSpec, args []string, r *Regist
ctx.Level = levelFlag
}
}
if err := validateIdParts(cs, vspec, ctx); err != nil {
if err := r.validateIdParts(cs, vspec, ctx); err != nil {
return nil, err
}
if !cs.NoAuth {
Expand DownExpand Up@@ -346,9 +346,10 @@ func resolveFlagValues(ctx *cmdctx.Ctx, cs *spec.CommandSpec) error {
return nil
}

func validateIdParts(cs *spec.CommandSpec, vspec VerbSpec, ctx *cmdctx.Ctx) error {
func (r *Registry) validateIdParts(cs *spec.CommandSpec, vspec VerbSpec, ctx *cmdctx.Ctx) error {
val, label := ctx.Id, cs.IdLabel
if vspec.AllowsParentId {
isParent := vspec.AllowsParentId
if isParent {
val = ctx.ParentId
if cs.ParentIdLabel != "" {
label = "<" + cs.ParentIdLabel + ">"
Expand All@@ -357,18 +358,69 @@ func validateIdParts(cs *spec.CommandSpec, vspec VerbSpec, ctx *cmdctx.Ctx) erro
if label == "" {
label = "<id>"
}
setVal := func(v string) {
if isParent {
ctx.ParentId = v
} else {
ctx.Id = v
}
}

// Whole id/parentid (id_parts <= 1): the default fn fires on omission or the
// explicit sentinel. "" only reaches here for commands that already treat a
// missing id/parentid as legal — RequiresId commands error before this point.
if cs.IdPartDefaultFn != "" && cs.IdParts <= 1 && (val == "" || val == spec.IdPartSentinel) {
resolved, err := r.resolveIdPartDefault(cs, ctx)
if err != nil {
return fmt.Errorf("%s: %w", label, err)
}
val = resolved
setVal(val)
}

if val == "" || cs.IdAllowSlash {
return nil
}
allowed := max(cs.IdParts-1, 0)
if got := strings.Count(val, "/"); got > allowed {
if cs.IdParts > 1 {
return fmt.Errorf("expected %s with exactly %d parts separated by '/', got %q", label, cs.IdParts, val)

if cs.IdParts <= 1 {
if strings.Contains(val, "/") {
return fmt.Errorf("%s %s: %s must not contain '/' (got %q)", cs.Verb, cs.Noun, label, val)
}
return fmt.Errorf("%s %s: %s must not contain '/' (got %q)", cs.Verb, cs.Noun, label, val)
return nil
}
if cs.IdParts > 1 {
ctx.IdParts = strings.SplitN(val, "/", cs.IdParts)

// Split id (id_parts > 1): the leading part defaults on an explicit sentinel
// ("./42") or on a bare, slash-free value ("42" instead of "<repo>/42").
allowed := cs.IdParts - 1
got := strings.Count(val, "/")
switch {
case got == allowed-1 && cs.IdPartDefaultFn != "":
resolved, err := r.resolveIdPartDefault(cs, ctx)
if err != nil {
return fmt.Errorf("%s: %w", label, err)
}
ctx.IdParts = append([]string{resolved}, strings.Split(val, "/")...)
case got == allowed:
parts := strings.SplitN(val, "/", cs.IdParts)
if cs.IdPartDefaultFn != "" && parts[0] == spec.IdPartSentinel {
resolved, err := r.resolveIdPartDefault(cs, ctx)
if err != nil {
return fmt.Errorf("%s: %w", label, err)
}
parts[0] = resolved
}
ctx.IdParts = parts
default:
return fmt.Errorf("expected %s with exactly %d parts separated by '/', got %q", label, cs.IdParts, val)
}
return nil
}

// resolveIdPartDefault invokes the command's registered id_part_default_fn.
func (r *Registry) resolveIdPartDefault(cs *spec.CommandSpec, ctx *cmdctx.Ctx) (string, error) {
fn := r.ResolveIdPartDefaultFn(cs.IdPartDefaultFn)
if fn == nil {
return "", fmt.Errorf("id_part_default_fn %q not registered", cs.IdPartDefaultFn)
}
return fn(ctx)
}
5 changes: 5 additions & 0 deletions pkg/registry/checks.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -119,6 +119,11 @@ func (r *Registry) checkFunctionsSpec(cs *spec.CommandSpec) []string {
errs = append(errs, fmt.Sprintf("command %q: follow_fn %q not registered", cs.Command, cs.FollowFn))
}
}
if cs.IdPartDefaultFn != "" {
if _, ok := r.idPartDefaultFns[cs.IdPartDefaultFn]; !ok {
errs = append(errs, fmt.Sprintf("command %q: id_part_default_fn %q not registered", cs.Command, cs.IdPartDefaultFn))
}
}
for _, f := range cs.Flags {
if f.CompletionFn != "" {
if _, ok := r.flagCompletionFns[f.CompletionFn]; !ok {
Expand Down
46 changes: 38 additions & 8 deletions pkg/registry/completion.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -189,12 +189,42 @@ func (r *Registry) wireSeqCompletion(cmd *cobra.Command, cs *spec.CommandSpec) {
hlog.Debug("completion: seq beyond last step", "stepIdx", stepIdx, "numSteps", len(steps))
return nil, cobra.ShellCompDirectiveNoFileComp
}
step := steps[stepIdx]
prefix := strings.Join(parts[:stepIdx], "/")
if prefix != "" {
prefix += "/"
queryParentId := strings.Join(parts[:stepIdx], "/")
queryStepIdx := stepIdx
outputPrefix := queryParentId
if outputPrefix != "" {
outputPrefix += "/"
}
hlog.Debug("completion: seq step", "stepIdx", stepIdx, "stepNoun", step.CompletionNoun, "prefix", prefix)

// A leading id_part can be defaulted from context (id_part_default_fn):
// either it's missing entirely (stepIdx==0, nothing typed for it yet) or
// the user typed the explicit sentinel "." for it. Either way the query
// needs a real value; outputPrefix is left untouched — it always echoes
// back exactly what the user already typed.
if cs.IdPartDefaultFn != "" {
resolveOnce := func() (string, bool) {
dctx, err := r.buildCompletionCtx(cmd, cs.Verb, cs.Noun, "")
if err != nil {
return "", false
}
resolved, err := r.resolveIdPartDefault(cs, dctx)
return resolved, err == nil
}
switch {
case stepIdx == 0 && len(steps) > 1:
if resolved, ok := resolveOnce(); ok {
queryStepIdx = 1
queryParentId = resolved
}
case len(parts) > 0 && parts[0] == spec.IdPartSentinel:
if resolved, ok := resolveOnce(); ok {
queryParentId = resolved
}
}
}

step := steps[queryStepIdx]
hlog.Debug("completion: seq step", "stepIdx", queryStepIdx, "stepNoun", step.CompletionNoun, "parentId", queryParentId)

var completions []string
if len(step.StaticValues) > 0 {
Expand All@@ -208,7 +238,7 @@ func (r *Registry) wireSeqCompletion(cmd *cobra.Command, cs *spec.CommandSpec) {
ep := listSpec.Endpoint
cspec := ep.Completion

ctx, err := r.buildCompletionCtx(cmd, VerbList, step.CompletionNoun, strings.Join(parts[:stepIdx], "/"))
ctx, err := r.buildCompletionCtx(cmd, VerbList, step.CompletionNoun, queryParentId)
if err != nil {
hlog.Debug("completion error: seq buildCtx", "stepNoun", step.CompletionNoun, "err", err)
return nil, cobra.ShellCompDirectiveError
Expand All@@ -228,7 +258,7 @@ func (r *Registry) wireSeqCompletion(cmd *cobra.Command, cs *spec.CommandSpec) {
}
hlog.Debug("completion: seq result", "stepNoun", step.CompletionNoun, "items", len(completions), "completions", len(completions))

isLastStep := stepIdx == len(steps)-1
isLastStep := queryStepIdx == len(steps)-1
directive := cobra.ShellCompDirectiveNoFileComp
if step.KeepOrder {
directive |= cobra.ShellCompDirectiveKeepOrder
Expand All@@ -248,7 +278,7 @@ func (r *Registry) wireSeqCompletion(cmd *cobra.Command, cs *spec.CommandSpec) {
c += "/"
}
}
completions[i] = prefix + c
completions[i] = outputPrefix + c
}
return completions, directive
}
Expand Down
1 change: 1 addition & 0 deletions pkg/registry/fields_test.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -100,6 +100,7 @@ func (tr *testResolver) ResolveQueryParamsFn(id string) cmdctx.QueryParamsFn
func (tr *testResolver) ResolveFetchFn(id string) (cmdctx.FetchFn, error) { return nil, nil }
func (tr *testResolver) ResolveListTransformFn(id string) cmdctx.ListTransformFn { return nil }
func (tr *testResolver) ResolveFlagResolveFn(id string) cmdctx.FlagResolveFn { return nil }
func (tr *testResolver) ResolveIdPartDefaultFn(id string) cmdctx.IdPartDefaultFn { return nil }
func (tr *testResolver) ResolveEndpointValidator(id string) cmdctx.EndpointValidatorFn {
return nil
}
Expand Down
10 changes: 10 additions & 0 deletions pkg/registry/moduleregistrar.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -34,6 +34,7 @@ type ModuleRegistrar interface {
RegisterListTransformFn(shortID string, fn cmdctx.ListTransformFn)
RegisterFlagCompletionFn(shortID string, fn FlagCompletionFn)
RegisterFlagResolveFn(shortID string, fn cmdctx.FlagResolveFn)
RegisterIdPartDefaultFn(shortID string, fn cmdctx.IdPartDefaultFn)
RegisterEndpointValidatorFn(shortID string, fn cmdctx.EndpointValidatorFn)
}

Expand DownExpand Up@@ -89,6 +90,9 @@ func (m *moduleRegistrar) Register(cs *spec.CommandSpec) error {
if cs.FollowFn != "" {
cs.FollowFn = m.qualify(cs.FollowFn, cmd+" follow_fn", true)
}
if cs.IdPartDefaultFn != "" {
cs.IdPartDefaultFn = m.qualify(cs.IdPartDefaultFn, cmd+" id_part_default_fn", true)
}
if cs.Endpoint != nil && cs.Endpoint.FetchFn != "" {
cs.Endpoint.FetchFn = m.qualify(cs.Endpoint.FetchFn, cmd+" fetch_fn", true)
}
Expand DownExpand Up@@ -174,6 +178,12 @@ func (m *moduleRegistrar) RegisterFlagResolveFn(shortID string, fn cmdctx.FlagRe
}
}

func (m *moduleRegistrar) RegisterIdPartDefaultFn(shortID string, fn cmdctx.IdPartDefaultFn) {
if q := m.qualify(shortID, fmt.Sprintf("id_part_default_fn %q", shortID), false); q != "" {
m.reg.RegisterIdPartDefaultFn(q, fn)
}
}

func (m *moduleRegistrar) RegisterEndpointValidatorFn(shortID string, fn cmdctx.EndpointValidatorFn) {
if q := m.qualify(shortID, fmt.Sprintf("endpoint_validator_fn %q", shortID), false); q != "" {
m.reg.RegisterEndpointValidatorFn(q, fn)
Expand Down
15 changes: 15 additions & 0 deletions pkg/registry/registry.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -64,6 +64,7 @@ type Registry struct {
listTransformFns map[string]cmdctx.ListTransformFn
flagCompletionFns map[string]FlagCompletionFn
flagResolveFns map[string]cmdctx.FlagResolveFn
idPartDefaultFns map[string]cmdctx.IdPartDefaultFn
endpointValidatorFns map[string]cmdctx.EndpointValidatorFn
initErrs []string
}
Expand All@@ -84,6 +85,7 @@ func New() *Registry {
listTransformFns: map[string]cmdctx.ListTransformFn{},
flagCompletionFns: map[string]FlagCompletionFn{},
flagResolveFns: map[string]cmdctx.FlagResolveFn{},
idPartDefaultFns: map[string]cmdctx.IdPartDefaultFn{},
endpointValidatorFns: map[string]cmdctx.EndpointValidatorFn{},
}
r.registerCoreFormatters()
Expand DownExpand Up@@ -398,6 +400,19 @@ func (r *Registry) RegisterFlagResolveFn(id string, fn cmdctx.FlagResolveFn) {
r.flagResolveFns[id] = fn
}

// ResolveIdPartDefaultFn implements cmdctx.Resolver.
func (r *Registry) ResolveIdPartDefaultFn(id string) cmdctx.IdPartDefaultFn {
return r.idPartDefaultFns[id]
}

// RegisterIdPartDefaultFn registers a fully-qualified id_part_default_fn ID.
func (r *Registry) RegisterIdPartDefaultFn(id string, fn cmdctx.IdPartDefaultFn) {
if _, ok := r.idPartDefaultFns[id]; ok {
panic(fmt.Sprintf("registry: duplicate id_part_default_fn %q", id))
}
r.idPartDefaultFns[id] = fn
}

// RegisterBodyFn registers a fully-qualified body constructor ID.
func (r *Registry) RegisterBodyFn(id string, fn cmdctx.CreateBodyFn) {
if _, ok := r.bodyFns[id]; ok {
Expand Down
5 changes: 5 additions & 0 deletions pkg/spec/spec.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -88,6 +88,10 @@ const (
PagingStrategyOffsetLimit = "offset_limit" // API accepts offset (items to skip) + limit; response has totalCount
)

// IdPartSentinel ("." — shell-style "current") opts a fully-specified id/parentid
// into id_part_default_fn, e.g. "." alone or "./42" for a 2-part id.
const IdPartSentinel = "."

// Valid presence values for MigrateFlag.
const (
MigratePresenceRequired = "required" // flag is registered and must be provided
Expand DownExpand Up@@ -560,6 +564,7 @@ type CommandSpec struct {
ArgsLabel string `yaml:"args_label,omitempty"` // appended to Usage after the id label (e.g. "<local-file>"); only used when has_args is true
IdParts int `yaml:"id_parts,omitempty"` // when > 1, id must contain exactly (id_parts-1) "/" separators; parts available as {ctx:id_part:0}, {ctx:id_part:1}, ...
IdAllowSlash bool `yaml:"id_allow_slash,omitempty"` // skip the slash-count validation on id (use when the id format has variable segments)
IdPartDefaultFn string `yaml:"id_part_default_fn,omitempty"` // registered IdPartDefaultFn name; supplies the leading id_part (or the whole id/parentid, for id_parts<=1) when it is omitted or given as IdPartSentinel ("."). See IdPartSentinel.
RequiresParentId bool `yaml:"requires_parentid,omitempty"` // list commands only: makes the [parentid] arg mandatory
ParentIdLabel string `yaml:"parentid_label,omitempty"` // overrides "[parentid]" in the Usage line for list commands (e.g. "<registry/name>")
Hidden bool `yaml:"hidden,omitempty"`
Expand Down