Skip to content

Commit

Permalink
Implement subcommands.
Browse files Browse the repository at this point in the history
The main command is now `run`.
Adds `help` and `version` commands.

Closes #33.
  • Loading branch information
Anaminus committed Jan 26, 2021
1 parent dcb1a80 commit 6c4d68a
Show file tree
Hide file tree
Showing 7 changed files with 387 additions and 121 deletions.
58 changes: 58 additions & 0 deletions rbxmk/cmd_help.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package main

import (
"bytes"
"fmt"

"github.com/anaminus/but"
"github.com/anaminus/rbxmk/rbxmk/cmds"
)

const globalUsage = `rbxmk is a tool for managing Roblox projects.
Usage:
rbxmk <command> [options]
Commands:
%s
Run "rbxmk help <command>" for more information about a command.
`

func init() {
Commands.Register(cmds.Command{
Name: "help",
Summary: "Display help.",
Usage: `rbxmk help [command]`,
Description: `
Displays help for a command, or general help if no command is given.`,
Func: HelpCommand,
})
}

// HelpCommand executes the help command.
func HelpCommand(flags cmds.Flags) {
name, ok := flags.ShiftArg()
but.IfFatal(flags.Parse(), "parse flags")
if ok {
if Commands.Has(name) {
cmd := Commands.Get(name)
flags.UsageOf(cmd)()
return
}
but.Logf("unknown command %q\n\n", name)
}
list := Commands.List()
width := 0
for _, cmd := range list {
if len(cmd.Name) > width {
width = len(cmd.Name)
}
}
var buf bytes.Buffer
for _, cmd := range list {
fmt.Fprintf(&buf, "\t%-*s %s\n", width, cmd.Name, cmd.Summary)
}
but.Logf(globalUsage, buf.String())
}
120 changes: 120 additions & 0 deletions rbxmk/cmd_run.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
package main

import (
"os"
"path/filepath"
"strconv"

"github.com/anaminus/but"
lua "github.com/anaminus/gopher-lua"
"github.com/anaminus/rbxmk"
"github.com/anaminus/rbxmk/formats"
"github.com/anaminus/rbxmk/library"
"github.com/anaminus/rbxmk/rbxmk/cmds"
"github.com/anaminus/rbxmk/sources"
)

// shortenPath transforms the given path so that it is relative to the working
// directory. Returns the original path if that fails.
func shortenPath(filename string) string {
if wd, err := os.Getwd(); err == nil {
if abs, err := filepath.Abs(filename); err == nil {
if r, err := filepath.Rel(wd, abs); err == nil {
filename = r
}
}
}
return filename
}

// ParseLuaValue parses a string into a Lua value. Numbers, bools, and nil are
// parsed into their respective types, and any other value is interpreted as a
// string.
func ParseLuaValue(s string) lua.LValue {
switch s {
case "true":
return lua.LTrue
case "false":
return lua.LFalse
case "nil":
return lua.LNil
}
if number, err := strconv.ParseFloat(s, 64); err == nil {
return lua.LNumber(number)
}
return lua.LString(s)
}

func init() {
Commands.Register(cmds.Command{
Name: "run",
Summary: "Execute a script.",
Usage: `rbxmk run [ FILE ] [ ...VALUE ]`,
Description: `
Receives a file to be executed as a Lua script. If "-" is given, then the script
will be read from stdin instead.
Remaining arguments are Lua values to be passed to the file. Numbers, bools, and
nil are parsed into their respective types in Lua, and any other value is
interpreted as a string. Within the script, these arguments can be received from
the ... operator.`,
Func: RunCommand,
})
}

// RunCommand executes the run command.
func RunCommand(flags cmds.Flags) {
but.IfFatal(Run(flags, nil))
}

// Run is the entrypoint to the command for running scripts. init runs after the
// World envrionment is fully initialized and arguments have been pushed, and
// before the script runs.
func Run(flags cmds.Flags, init func(rbxmk.State)) error {
// Parse flags.
but.IfFatal(flags.Parse(), "parse flags")
args := flags.Args()
if len(args) == 0 {
flags.Usage()
return nil
}
file := args[0]
args = args[1:]

// Initialize world.
world := rbxmk.NewWorld(lua.NewState(lua.Options{
SkipOpenLibs: true,
IncludeGoStackTrace: false,
}))
for _, f := range formats.All() {
world.RegisterFormat(f())
}
for _, s := range sources.All() {
world.RegisterSource(s())
}
for _, lib := range library.All() {
if err := world.Open(lib); err != nil {
but.Fatal(err)
}
}

world.State().SetGlobal("_RBXMK_VERSION", lua.LString(Version))

// Add script arguments.
for _, arg := range args {
world.State().Push(ParseLuaValue(arg))
}

if init != nil {
init(rbxmk.State{World: world, L: world.State()})
}

// Run stdin as script.
if file == "-" {
return world.DoFileHandle(flags.Stdin, len(args))
}

// Run file as script.
filename := shortenPath(filepath.Clean(file))
return world.DoFile(filename, len(args))
}
8 changes: 3 additions & 5 deletions rbxmk/main_test.go → rbxmk/cmd_run_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (

lua "github.com/anaminus/gopher-lua"
"github.com/anaminus/rbxmk"
"github.com/anaminus/rbxmk/rbxmk/cmds"
"github.com/anaminus/rbxmk/rtypes"
"github.com/robloxapi/types"
)
Expand Down Expand Up @@ -186,11 +187,8 @@ func TestScripts(t *testing.T) {
t.Run(filepath.ToSlash(file), func(t *testing.T) {
args := scriptArguments
args[1] = file
err := Main(args[:], Std{
in: os.Stdin,
out: os.Stdout,
err: os.Stderr,
}, func(s rbxmk.State) { initMain(s, t) })
flags := cmds.NewFlags(args[0], args[1:])
err := Run(flags, func(s rbxmk.State) { initMain(s, t) })
if err != nil {
t.Errorf("script %s: %s", file, err)
}
Expand Down
23 changes: 23 additions & 0 deletions rbxmk/cmd_version.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package main

import (
"github.com/anaminus/but"
"github.com/anaminus/rbxmk/rbxmk/cmds"
)

func init() {
Commands.Register(cmds.Command{
Name: "version",
Summary: "Display the version.",
Usage: `rbxmk version`,
Description: `
Displays the current version of rbxmk.`,
Func: VersionCommand,
})
}

// VersionCommand executes the version command.
func VersionCommand(flags cmds.Flags) {
but.IfFatal(flags.Parse(), "parse flags")
but.Log(Version)
}
166 changes: 166 additions & 0 deletions rbxmk/cmds/cmds.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
package cmds

import (
"flag"
"fmt"
"os"
"sort"
"strings"
)

// FileReader represents a file that can be read from, and includes file
// information.
type FileReader interface {
Name() string
Stat() (os.FileInfo, error)
Read([]byte) (int, error)
}

// FileWriter represents a file that can be written to, and includes file
// information.
type FileWriter interface {
Name() string
Stat() (os.FileInfo, error)
Write([]byte) (int, error)
}

// Flags bundles a FlagSet with arguments and file descriptors.
type Flags struct {
*flag.FlagSet
Stdin FileReader
Stdout FileWriter
Stderr FileWriter

args []string
}

// NewFlags returns an initialized Flags. name is the name of the program. args
// are the arguments to be parsed with the embedded FlagSet. The Flags' file
// descriptors are set to the standard file descriptors.
func NewFlags(name string, args []string) Flags {
return Flags{
FlagSet: flag.NewFlagSet(name, flag.ExitOnError),
args: args,
Stdin: os.Stdin,
Stdout: os.Stdout,
Stderr: os.Stderr,
}
}

// ShiftArg attempts to return the first argument of Flags. If successful, the
// first argument is removed, shifting down the remaining arguments.
func (f *Flags) ShiftArg() (arg string, ok bool) {
if len(f.args) == 0 {
return "", false
}
arg = f.args[0]
f.args = f.args[1:]
return arg, true
}

// formatDesc formats a command description for readability.
func formatDesc(s string) string {
s = strings.TrimSpace(s)
//TODO: Wrap to 80 characters.
return s
}

// UsageOf returns a Usage function constructed from cmd.
func (f *Flags) UsageOf(cmd Command) func() {
var usage string
var desc string
if cmd.Usage != "" {
usage = cmd.Usage
}
if cmd.Description != "" {
desc = formatDesc(cmd.Description)
}
return func() {
if usage != "" {
fmt.Fprintf(f.Output(), "Usage: %s\n\n", usage)
}
if desc != "" {
fmt.Fprintf(f.Output(), "%s", desc)
}
f.PrintDefaults()
}
}

// Parse parses the Flags' arguments with the FlagSet.
func (f Flags) Parse() error {
return f.FlagSet.Parse(f.args)
}

// Command describes a subcommand to be run within the program.
type Command struct {
// Name is the name of the command.
Name string

// Summary is a short description of the command.
Summary string

// Usage describes the structure of the command.
Usage string

// Description is a detailed description of the command.
Description string

// Func is the function that runs when the command is invoked.
Func func(Flags)
}

// Commands maps a name to a Command.
type Commands struct {
// Name is the name of the program.
Name string

m map[string]Command
}

// NewCommands returns an initialized commands.
func NewCommands(name string) Commands {
return Commands{
Name: name,
m: map[string]Command{},
}
}

// Register registers cmd as cmd.Name.
func (c Commands) Register(cmd Command) {
c.m[cmd.Name] = cmd
}

// Has returns whether name is a registered command.
func (c Commands) Has(name string) bool {
_, ok := c.m[name]
return ok
}

// Get returns the Command mapped to the given name.
func (c Commands) Get(name string) Command {
return c.m[name]
}

// List returns a list of commands, sorted by name.
func (c Commands) List() []Command {
list := make([]Command, 0, len(c.m))
for _, cmd := range c.m {
list = append(list, cmd)
}
sort.Slice(list, func(i, j int) bool {
return list[i].Name < list[j].Name
})
return list
}

// Do executes the Command mapped to the given name. Does nothing if the name is
// not defined.
func (c Commands) Do(name string, args []string) {
cmd := c.m[name]
if cmd.Func == nil {
return
}
flags := NewFlags(c.Name, args)
flags.Usage = flags.UsageOf(cmd)
cmd.Func(flags)
}
Loading

0 comments on commit 6c4d68a

Please sign in to comment.