Skip to content
Draft
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
20 changes: 20 additions & 0 deletions commands_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,26 @@ func TestExpand(t *testing.T) {
}
}

func TestToCommandsThreadsArgsToDeepDescendants(t *testing.T) {
descriptors := []*commandDescriptor{
{Name: "a", Commands: []*commandDescriptor{
{Name: "b", Commands: []*commandDescriptor{
{Name: "c", Commands: []*commandDescriptor{
{Name: "d1"},
{Name: "d2"},
}},
}},
}},
}
parent := &executableCommand{path: "/tool", name: "tool"}

cmds := toCommands(parent, descriptors, nil, &discoverer{maxDepth: -1})

c := cmds.Find("a").(*executableCommand).cmds.Find("b").(*executableCommand).cmds.Find("c").(*executableCommand)
assert.Equal(t, []string{"a", "b", "c", "d1"}, c.cmds.Find("d1").(*executableCommand).args)
assert.Equal(t, []string{"a", "b", "c", "d2"}, c.cmds.Find("d2").(*executableCommand).args)
}

func TestExpandWithDepthZeroDoesNotCallSubcommands(t *testing.T) {
stub := &stubParent{name: "stub"}

Expand Down
5 changes: 5 additions & 0 deletions contract.go
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,11 @@ type commandDescriptor struct {
// is not populated; children are represented by Commands above. It is nil for
// commands discovered via contracts other than OpenCLI.
openCLI *opencli.Command

// describedBy, when set, indicates that the command can describe its own
// subcommands (e.g. by responding to --help-opencli) even though Commands
// is empty. toCommands uses it to defer discovery to the command itself.
describedBy describeFunc
}

func readSummaryFromShellScript(cmd *shellScriptCommand) (string, error) {
Expand Down
23 changes: 22 additions & 1 deletion contract_opencli.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ import (
//
// Unlike ExecutableContract, this contract does not require any particular
// file extension. Any executable file is eligible.
//
// A document may describe its subcommands progressively: when a subcommand's
// entry lists no children but declares the option "--help-opencli", its
// subcommands are discovered on demand by invoking the subcommand with
// --help-opencli (e.g. `tool sub --help-opencli`).
type OpenCLIContract struct{}

// OpenCLIDescriber is implemented by Commands that can describe themselves
Expand Down Expand Up @@ -78,7 +83,10 @@ func (c *OpenCLIContract) BuildCommand(path string, info fs.DirEntry, parent Com
// describeOpenCLI is a describeFunc that invokes --help-opencli
// and parses the OpenCLI JSON output into a commandDescriptor.
func describeOpenCLI(cmd *executableCommand) (*commandDescriptor, error) {
out, err := cmd.cache.Fetch(cmd, "help-opencli", func() (string, error) {
// Subcommands that respond to --help-opencli themselves share the
// executable's path, so the cache key must include their arguments.
key := strings.Join(append([]string{"help-opencli"}, cmd.args...), " ")
out, err := cmd.cache.Fetch(cmd, key, func() (string, error) {
return helpOpenCLIRaw(cmd)
})
if err != nil {
Expand Down Expand Up @@ -150,6 +158,19 @@ func opencliToDescriptor(cmd opencli.Command) *commandDescriptor {
for i, sub := range cmd.Commands {
d.Commands[i] = opencliToDescriptor(sub)
}
} else if respondsToHelpOpenCLI(cmd) {
d.describedBy = describeOpenCLI
}
return d
}

// respondsToHelpOpenCLI returns true if the command declares --help-opencli
// among its options, signaling that it can describe its own subcommands.
func respondsToHelpOpenCLI(cmd opencli.Command) bool {
for _, o := range cmd.Options {
if o.Name == "--help-opencli" {
return true
}
}
return false
}
80 changes: 79 additions & 1 deletion contract_opencli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package exoskeleton
import (
"io/fs"
"os"
"os/exec"
"path/filepath"
"testing"

Expand Down Expand Up @@ -88,10 +89,11 @@ func TestOpenCLICommandDiscovery(t *testing.T) {
// Subcommands are discovered from OpenCLI output
cmds, err := cmd.Subcommands()
assert.NoError(t, err)
assert.Len(t, cmds, 3)
assert.Len(t, cmds, 4)
assert.Equal(t, "build", cmds[0].Name())
assert.Equal(t, "mod", cmds[1].Name())
assert.Equal(t, "hidden-cmd", cmds[2].Name())
assert.Equal(t, "google-drive", cmds[3].Name())

// Nested subcommands
modCmds, err := cmds[1].Subcommands()
Expand Down Expand Up @@ -147,6 +149,82 @@ func TestOpenCLICommandExposesMetadata(t *testing.T) {
assert.Empty(t, node.Commands)
}

// buildOpenCLITool builds a Command from the opencli-tool fixture.
func buildOpenCLITool(t *testing.T, d *discoverer) Command {
t.Helper()
path := filepath.Join(fixtures, "opencli-tool")
info, err := os.Lstat(path)
assert.NoError(t, err)
cmd, err := (&OpenCLIContract{}).BuildCommand(path, fs.FileInfoToDirEntry(info), nil, d)
assert.NoError(t, err)
return cmd
}

func TestOpenCLIProgressiveDiscovery(t *testing.T) {
execs := 0
countingExecutor := func(c *exec.Cmd) error { execs++; return c.Run() }
cmd := buildOpenCLITool(t, &discoverer{maxDepth: -1, executor: countingExecutor})

cmds, err := cmd.Subcommands()
assert.NoError(t, err)
assert.Equal(t, 1, execs)

// The root document lists no subcommands for google-drive but declares
// that it responds to --help-opencli. Its sigil and summary come from
// the root document without executing anything...
gd := cmds.Find("google-drive")
assert.True(t, HasSubcommands(gd))
summary, err := gd.Summary()
assert.NoError(t, err)
assert.Equal(t, "Work with Google Drive", summary)
assert.Equal(t, 1, execs)

// ...and Subcommands() invokes `opencli-tool google-drive --help-opencli`.
gdCmds, err := gd.Subcommands()
assert.NoError(t, err)
assert.Equal(t, 2, execs)
assert.Len(t, gdCmds, 2)

// Descendants are invoked with the full path of arguments.
docs := gdCmds[0].(*executableCommand)
assert.Equal(t, []string{cmd.Path(), "google-drive", "docs"}, docs.Command().Args)

// OpenCLICommand() returns google-drive's own description of itself,
// which is richer than its entry in the root document.
node, err := gd.(OpenCLIDescriber).OpenCLICommand()
assert.NoError(t, err)
assert.Equal(t, "Work with files in Google Drive", *node.Description)
}

func TestOpenCLIProgressiveDiscoveryRespectsMaxDepth(t *testing.T) {
cmd := buildOpenCLITool(t, &discoverer{maxDepth: 1, executor: defaultExecutor})

cmds, err := cmd.Subcommands()
assert.NoError(t, err)

gdCmds, err := cmds.Find("google-drive").Subcommands()
assert.NoError(t, err)
assert.Empty(t, gdCmds)
}

func TestOpenCLIProgressiveDiscoveryCacheKeysDoNotCollide(t *testing.T) {
cachePath := filepath.Join(t.TempDir(), "cache.json")

subcommandsOfGoogleDrive := func() Commands {
cmd := buildOpenCLITool(t, &discoverer{maxDepth: -1, executor: defaultExecutor, cache: &FileCache{Path: cachePath}})
cmds, err := cmd.Subcommands()
assert.NoError(t, err)
gdCmds, err := cmds.Find("google-drive").Subcommands()
assert.NoError(t, err)
return gdCmds
}

// The second call reads google-drive's document from the cache; a key
// collision would return the root document instead.
assert.Len(t, subcommandsOfGoogleDrive(), 2)
assert.Len(t, subcommandsOfGoogleDrive(), 2)
}

func TestOpenCLICommandForNonOpenCLIContract(t *testing.T) {
// A command that was not discovered via the OpenCLI contract has no
// OpenCLI metadata; its richer fields come from the Command interface.
Expand Down
26 changes: 19 additions & 7 deletions executable_command.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ func (cmd *executableCommand) Complete(_ *Entrypoint, args, env []string) ([]str
// The executable is expected to write the summary to standard output and exit
// successfully.
func (cmd *executableCommand) Summary() (string, error) {
if cmd.discoverer != nil && cmd.cmds == nil {
if cmd.summary == nil && cmd.discoverer != nil && cmd.cmds == nil {
if err := cmd.discover(); err != nil {
return "", err
}
Expand Down Expand Up @@ -184,7 +184,7 @@ func (cmd *executableCommand) discover() error {
cmd.summary = descriptor.Summary
cmd.defaultSubcommand = descriptor.DefaultCommand
cmd.openCLI = descriptor.openCLI
cmd.cmds = toCommands(cmd, descriptor.Commands, nil, cmd.discoverer)
cmd.cmds = toCommands(cmd, descriptor.Commands, cmd.args, cmd.discoverer)
return nil
}

Expand All @@ -195,7 +195,9 @@ func (cmd *executableCommand) discover() error {
// The returned Command describes only this node. Its Commands field is not
// populated; walk Subcommands() to describe the command tree.
func (cmd *executableCommand) OpenCLICommand() (*opencli.Command, error) {
if cmd.openCLI == nil && cmd.discoverer != nil && cmd.cmds == nil {
// Discover before answering: a command that describes its own subcommands
// provides a richer self-description than its entry in its parent's document.
if cmd.discoverer != nil && cmd.cmds == nil {
if err := cmd.discover(); err != nil {
return nil, err
}
Expand All @@ -210,11 +212,14 @@ func (cmd *executableCommand) OpenCLICommand() (*opencli.Command, error) {
func toCommands(parent *executableCommand, descriptors []*commandDescriptor, args []string, d DiscoveryContext) Commands {
cmds := Commands{}
for _, descriptor := range descriptors {
// Allocate exactly, so that sibling commands never share a backing array.
cmdArgs := append(append(make([]string, 0, len(args)+1), args...), descriptor.Name)

c := &executableCommand{
parent: parent,
discoveredIn: parent.discoveredIn,
path: parent.path,
args: append(args, descriptor.Name),
args: cmdArgs,
name: descriptor.Name,
aliases: descriptor.Aliases,
summary: descriptor.Summary,
Expand All @@ -225,9 +230,16 @@ func toCommands(parent *executableCommand, descriptors []*commandDescriptor, arg
contract: parent.contract,
}

if len(descriptor.Commands) > 0 && d.MaxDepth() != 0 {
c.discoverer = d.Next()
c.cmds = toCommands(c, descriptor.Commands, append(args, c.name), d.Next())
if d.MaxDepth() != 0 {
if len(descriptor.Commands) > 0 {
c.discoverer = d.Next()
c.cmds = toCommands(c, descriptor.Commands, cmdArgs, d.Next())
} else if descriptor.describedBy != nil {
// The command can describe its own subcommands: defer
// discovery until Subcommands() is called.
c.discoverer = d.Next()
c.describe = descriptor.describedBy
}
}
cmds = append(cmds, c)
}
Expand Down
30 changes: 30 additions & 0 deletions fixtures/opencli-tool
Original file line number Diff line number Diff line change
@@ -1,6 +1,29 @@
#!/usr/bin/env bash
# SUMMARY: An OpenCLI tool

if [[ "$1" == "google-drive" && "$2" == "--help-opencli" ]]; then
cat << EOF
{
"opencli": "0.1-block.1",
"name": "google-drive",
"info": {"version": "1.0.0"},
"summary": "Work with Google Drive",
"description": "Work with files in Google Drive",
"commands": [
{
"name": "docs",
"summary": "Work with Google Docs"
},
{
"name": "sheets",
"summary": "Work with Google Sheets"
}
]
}
EOF
exit 0
fi

if [[ "$1" == "--help-opencli" ]]; then
cat << EOF
{
Expand Down Expand Up @@ -39,6 +62,13 @@ if [[ "$1" == "--help-opencli" ]]; then
"arguments": [
{"name": "file"}
]
},
{
"name": "google-drive",
"summary": "Work with Google Drive",
"options": [
{"name": "--help-opencli", "hidden": true}
]
}
]
}
Expand Down
Loading