diff --git a/README.md b/README.md index 48f59f6..16ce48d 100644 --- a/README.md +++ b/README.md @@ -90,8 +90,9 @@ Rudi comes with a standalone CLI tool called `rudi`. Usage of rudi: -i, --interactive Start an interactive REPL to run expressions. -s, --script string Load Rudi script from file instead of first argument (only in non-interactive mode). - -f, --stdin-format string What data format is used for data provided on stdin, one of [json yaml toml]. (default "yaml") - -o, --output-format string What data format to use for outputting data (if not given, unformatted JSON is used), one of [json yaml toml]. + --var stringArray Define additional global variables (can be given multiple times). + -f, --stdin-format string What data format is used for data provided on stdin, one of [raw json yaml toml]. (default "yaml") + -o, --output-format string What data format to use for outputting data, one of [raw json yaml toml]. (default "json") --enable-funcs Enable the func! function to allow defining new functions in Rudi code. -c, --coalesce string Type conversion handling, one of [strict pedantic humane]. (default "strict") -h, --help Show help and documentation. diff --git a/cmd/rudi/cmd/console/command.go b/cmd/rudi/cmd/console/command.go index 9c90d10..29d6bc2 100644 --- a/cmd/rudi/cmd/console/command.go +++ b/cmd/rudi/cmd/console/command.go @@ -11,7 +11,7 @@ import ( "go.xrstf.de/rudi" "go.xrstf.de/rudi/cmd/rudi/docs" - cmdtypes "go.xrstf.de/rudi/cmd/rudi/types" + "go.xrstf.de/rudi/cmd/rudi/options" "go.xrstf.de/rudi/cmd/rudi/util" "go.xrstf.de/rudi/pkg/eval/types" @@ -19,7 +19,7 @@ import ( "github.com/chzyer/readline" ) -func helpCommand(ctx types.Context, opts *cmdtypes.Options) error { +func helpCommand(ctx types.Context, opts *options.Options) error { content, err := docs.RenderFile("cmd-console.md", nil) if err != nil { return err @@ -41,13 +41,13 @@ func helpTopicCommand(topic string) error { return nil } -type replCommandFunc func(ctx types.Context, opts *cmdtypes.Options) error +type replCommandFunc func(ctx types.Context, opts *options.Options) error var replCommands = map[string]replCommandFunc{ "help": helpCommand, } -func Run(handler *util.SignalHandler, opts *cmdtypes.Options, args []string, rudiVersion string) error { +func Run(handler *util.SignalHandler, opts *options.Options, args []string, rudiVersion string) error { rl, err := readline.New("⮞ ") if err != nil { return fmt.Errorf("failed to setup readline prompt: %w", err) @@ -107,7 +107,7 @@ func Run(handler *util.SignalHandler, opts *cmdtypes.Options, args []string, rud return nil } -func processInput(handler *util.SignalHandler, rudiCtx types.Context, opts *cmdtypes.Options, input string) (newCtx types.Context, stop bool, err error) { +func processInput(handler *util.SignalHandler, rudiCtx types.Context, opts *options.Options, input string) (newCtx types.Context, stop bool, err error) { if command, exists := replCommands[input]; exists { return rudiCtx, false, command(rudiCtx, opts) } diff --git a/cmd/rudi/cmd/help/command.go b/cmd/rudi/cmd/help/command.go index 58f011e..4d8f42a 100644 --- a/cmd/rudi/cmd/help/command.go +++ b/cmd/rudi/cmd/help/command.go @@ -7,13 +7,13 @@ import ( "fmt" "go.xrstf.de/rudi/cmd/rudi/docs" - "go.xrstf.de/rudi/cmd/rudi/types" + "go.xrstf.de/rudi/cmd/rudi/options" "go.xrstf.de/rudi/cmd/rudi/util" "github.com/spf13/pflag" ) -func Run(opts *types.Options, args []string) error { +func Run(opts *options.Options, args []string) error { // do not show function docs for "--help help if" if !opts.ShowHelp && len(args) == 2 && args[0] == "help" { rendered, err := util.RenderHelpTopic(args[1], 0) diff --git a/cmd/rudi/cmd/script/command.go b/cmd/rudi/cmd/script/command.go index 6ff6ec4..df61b97 100644 --- a/cmd/rudi/cmd/script/command.go +++ b/cmd/rudi/cmd/script/command.go @@ -12,6 +12,7 @@ import ( "strings" "go.xrstf.de/rudi" + "go.xrstf.de/rudi/cmd/rudi/options" "go.xrstf.de/rudi/cmd/rudi/types" "go.xrstf.de/rudi/cmd/rudi/util" "go.xrstf.de/rudi/pkg/debug" @@ -20,7 +21,7 @@ import ( "gopkg.in/yaml.v3" ) -func Run(handler *util.SignalHandler, opts *types.Options, args []string) error { +func Run(handler *util.SignalHandler, opts *options.Options, args []string) error { // determine input script to evaluate var ( script string @@ -101,7 +102,7 @@ func Run(handler *util.SignalHandler, opts *types.Options, args []string) error encoder = toml.NewEncoder(os.Stdout) encoder.(*toml.Encoder).Indent = " " default: - encoder = json.NewEncoder(os.Stdout) + encoder = &rawEncoder{} } if err := encoder.Encode(evaluated); err != nil { @@ -110,3 +111,10 @@ func Run(handler *util.SignalHandler, opts *types.Options, args []string) error return nil } + +type rawEncoder struct{} + +func (e *rawEncoder) Encode(v any) error { + fmt.Println(v) + return nil +} diff --git a/cmd/rudi/encoding/decode.go b/cmd/rudi/encoding/decode.go new file mode 100644 index 0000000..72ce090 --- /dev/null +++ b/cmd/rudi/encoding/decode.go @@ -0,0 +1,52 @@ +// SPDX-FileCopyrightText: 2023 Christoph Mewes +// SPDX-License-Identifier: MIT + +package encoding + +import ( + "encoding/json" + "fmt" + "io" + + "go.xrstf.de/rudi/cmd/rudi/types" + + "github.com/BurntSushi/toml" + "gopkg.in/yaml.v3" +) + +func Decode(input io.Reader, enc types.Encoding) (any, error) { + var data any + + switch enc { + case types.RawEncoding: + content, err := io.ReadAll(input) + if err != nil { + return nil, fmt.Errorf("failed to read input: %w", err) + } + + data = string(content) + + case types.JsonEncoding: + decoder := json.NewDecoder(input) + if err := decoder.Decode(&data); err != nil { + return nil, fmt.Errorf("failed to parse file as JSON: %w", err) + } + + case types.YamlEncoding: + decoder := yaml.NewDecoder(input) + if err := decoder.Decode(&data); err != nil { + return nil, fmt.Errorf("failed to parse file as YAML: %w", err) + } + + case types.TomlEncoding: + decoder := toml.NewDecoder(input) + if _, err := decoder.Decode(&data); err != nil { + return nil, fmt.Errorf("failed to parse file as TOML: %w", err) + } + + default: + return nil, fmt.Errorf("unexpected encoding %q", enc) + } + + return data, nil +} diff --git a/cmd/rudi/main.go b/cmd/rudi/main.go index 8cbb0c8..05b6846 100644 --- a/cmd/rudi/main.go +++ b/cmd/rudi/main.go @@ -15,7 +15,7 @@ import ( "go.xrstf.de/rudi/cmd/rudi/cmd/console" "go.xrstf.de/rudi/cmd/rudi/cmd/help" "go.xrstf.de/rudi/cmd/rudi/cmd/script" - "go.xrstf.de/rudi/cmd/rudi/types" + "go.xrstf.de/rudi/cmd/rudi/options" "go.xrstf.de/rudi/cmd/rudi/util" "github.com/spf13/pflag" @@ -86,7 +86,7 @@ func printVersion() { } func main() { - opts := types.NewDefaultOptions() + opts := options.NewDefaultOptions() opts.AddFlags(pflag.CommandLine) pflag.Parse() diff --git a/cmd/rudi/options/options.go b/cmd/rudi/options/options.go new file mode 100644 index 0000000..1d2bb8b --- /dev/null +++ b/cmd/rudi/options/options.go @@ -0,0 +1,150 @@ +// SPDX-FileCopyrightText: 2023 Christoph Mewes +// SPDX-License-Identifier: MIT + +package options + +import ( + "errors" + "fmt" + "io" + "os" + "regexp" + "strings" + + "go.xrstf.de/rudi/cmd/rudi/encoding" + "go.xrstf.de/rudi/cmd/rudi/types" + + "github.com/spf13/pflag" +) + +type Options struct { + ShowHelp bool + Interactive bool + ScriptFile string + StdinFormat types.Encoding + OutputFormat types.Encoding + PrintAst bool + ShowVersion bool + Coalescing types.Coalescing + EnableRudispaceFunctions bool + ExtraVariables map[string]any + extraVariableFlags []string +} + +func NewDefaultOptions() Options { + return Options{ + Coalescing: types.StrictCoalescing, + StdinFormat: types.YamlEncoding, + OutputFormat: types.JsonEncoding, + ExtraVariables: map[string]any{}, + } +} + +func (o *Options) AddFlags(fs *pflag.FlagSet) { + fs.SortFlags = false + + stdinFormatFlag := newEnumFlag(&o.StdinFormat, types.AllEncodings...) + outputFormatFlag := newEnumFlag(&o.OutputFormat, types.AllEncodings...) + coalescingFlag := newEnumFlag(&o.Coalescing, types.AllCoalescings...) + + fs.BoolVarP(&o.Interactive, "interactive", "i", o.Interactive, "Start an interactive REPL to run expressions.") + fs.StringVarP(&o.ScriptFile, "script", "s", o.ScriptFile, "Load Rudi script from file instead of first argument (only in non-interactive mode).") + fs.StringArrayVar(&o.extraVariableFlags, "var", o.extraVariableFlags, "Define additional global variables (can be given multiple times).") + stdinFormatFlag.Add(fs, "stdin-format", "f", "What data format is used for data provided on stdin") + outputFormatFlag.Add(fs, "output-format", "o", "What data format to use for outputting data") + fs.BoolVar(&o.EnableRudispaceFunctions, "enable-funcs", o.EnableRudispaceFunctions, "Enable the func! function to allow defining new functions in Rudi code.") + coalescingFlag.Add(fs, "coalesce", "c", "Type conversion handling") + fs.BoolVarP(&o.ShowHelp, "help", "h", o.ShowHelp, "Show help and documentation.") + fs.BoolVarP(&o.ShowVersion, "version", "V", o.ShowVersion, "Show version and exit.") + fs.BoolVarP(&o.PrintAst, "debug-ast", "", o.PrintAst, "Output syntax tree of the parsed script in non-interactive mode.") +} + +func (o *Options) Validate() error { + if o.Interactive && o.ScriptFile != "" { + return errors.New("cannot combine --interactive with --script") + } + + if o.Interactive && o.PrintAst { + return errors.New("cannot combine --interactive with --debug-ast") + } + + if err := o.parseExtraVariables(); err != nil { + return fmt.Errorf("invalid --var flags: %w", err) + } + + return nil +} + +var extraVariableFlagFormat = regexp.MustCompile(`^([a-zA-Z_][a-zA-Z0-9_]*)=([a-z]+):([a-z]+):(.+)$`) + +func (o *Options) parseExtraVariables() error { + for i, flagValue := range o.extraVariableFlags { + varName, value, err := o.parseExtraVariable(flagValue) + if err != nil { + return fmt.Errorf("--var flag %d: %w", i, err) + } + + o.ExtraVariables[varName] = value + } + + return nil +} + +func (o *Options) parseExtraVariable(flagValue string) (string, any, error) { + flagValue = strings.TrimSpace(flagValue) + + match := extraVariableFlagFormat.FindStringSubmatch(flagValue) + if match == nil { + return "", nil, errors.New("must be in the form of \"varname=encoding:source:data\"") + } + + varName := match[1] + enc := types.Encoding(match[2]) + source := types.VariableSource(match[3]) + data := match[4] + + // validate the given parameters for this variable + + if _, exists := o.ExtraVariables[varName]; exists { + return "", nil, fmt.Errorf("variable $%s is defined multiple times", varName) + } + + if !enc.IsValid() { + return "", nil, fmt.Errorf("invalid encoding %q, must be one of %v", enc, types.AllEncodings) + } + + if !source.IsValid() { + return "", nil, fmt.Errorf("invalid source type %q, must be one of %v", source, types.AllVariableSources) + } + + // resolve the variable source + + var input io.Reader + + switch source { + case types.StringVariableSource: + input = strings.NewReader(data) + case types.EnvironmentVariableSource: + input = strings.NewReader(os.Getenv(data)) + case types.FileVariableSource: + f, err := os.Open(data) + if err != nil { + return "", nil, fmt.Errorf("failed to open %q: %w", data, err) + } + defer f.Close() + + input = f + default: + // This should never happen. + return "", nil, fmt.Errorf("unknown source type %q", source) + } + + // parse the data as requested + + varData, err := encoding.Decode(input, enc) + if err != nil { + return "", nil, fmt.Errorf("failed to decode data: %w", err) + } + + return varName, varData, nil +} diff --git a/cmd/rudi/types/pflag.go b/cmd/rudi/options/pflag.go similarity index 62% rename from cmd/rudi/types/pflag.go rename to cmd/rudi/options/pflag.go index e9bf868..fe3d653 100644 --- a/cmd/rudi/types/pflag.go +++ b/cmd/rudi/options/pflag.go @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: 2023 Christoph Mewes // SPDX-License-Identifier: MIT -package types +package options import ( "fmt" @@ -10,15 +10,20 @@ import ( "github.com/spf13/pflag" ) +type enumValue interface { + fmt.Stringer + IsValid() bool +} + type enumFlag struct { - target fmt.Stringer - values []string + target enumValue + values []enumValue } -func newEnumFlag(value fmt.Stringer, possibleValues ...fmt.Stringer) *enumFlag { - values := make([]string, len(possibleValues)) - for i, v := range possibleValues { - values[i] = v.String() +func newEnumFlag[T enumValue, V enumValue](value T, possibleValues ...V) *enumFlag { + values := make([]enumValue, len(possibleValues)) + for i, pv := range possibleValues { + values[i] = pv } return &enumFlag{ @@ -34,22 +39,13 @@ func (f *enumFlag) Add(fs *pflag.FlagSet, longFlag string, shortFlag string, usa var _ pflag.Value = &enumFlag{} func (f *enumFlag) Set(s string) error { - exists := false - for _, v := range f.values { - if v == s { - exists = true - } - } - - if !exists { + newValue := f.stringToEnumValue(s) + if !newValue.IsValid() { return fmt.Errorf("invalid value %q, must be one of %v", s, f.values) } - tt := reflect.TypeOf(f.target).Elem() // e.g. turn *Coalescer type into Coalescer - newValue := reflect.ValueOf(s).Convert(tt) // convert string to Coalescer - // replace value in the target - reflect.ValueOf(f.target).Elem().Set(newValue) + reflect.ValueOf(f.target).Elem().Set(reflect.ValueOf(newValue)) return nil } @@ -61,3 +57,10 @@ func (f *enumFlag) String() string { func (*enumFlag) Type() string { return "string" } + +func (f *enumFlag) stringToEnumValue(s string) enumValue { + tt := reflect.TypeOf(f.target).Elem() // e.g. turn *Coalescer type into Coalescer + newValue := reflect.ValueOf(s).Convert(tt) // convert string to Coalescer + + return newValue.Interface().(enumValue) +} diff --git a/cmd/rudi/types/const.go b/cmd/rudi/types/const.go new file mode 100644 index 0000000..9b74fa3 --- /dev/null +++ b/cmd/rudi/types/const.go @@ -0,0 +1,93 @@ +// SPDX-FileCopyrightText: 2023 Christoph Mewes +// SPDX-License-Identifier: MIT + +package types + +// All of these constants must be lowercased because the validation function +// normalizes the given user input to lowercase. + +type Encoding string + +func (e Encoding) String() string { + return string(e) +} + +func (e Encoding) IsValid() bool { + for _, enc := range AllEncodings { + if enc == e { + return true + } + } + + return false +} + +const ( + RawEncoding Encoding = "raw" + JsonEncoding Encoding = "json" + YamlEncoding Encoding = "yaml" + TomlEncoding Encoding = "toml" +) + +var AllEncodings = []Encoding{ + RawEncoding, + JsonEncoding, + YamlEncoding, + TomlEncoding, +} + +type Coalescing string + +func (c Coalescing) String() string { + return string(c) +} + +func (c Coalescing) IsValid() bool { + for _, coal := range AllCoalescings { + if coal == c { + return true + } + } + + return false +} + +const ( + StrictCoalescing Coalescing = "strict" + PedanticCoalescing Coalescing = "pedantic" + HumaneCoalescing Coalescing = "humane" +) + +var AllCoalescings = []Coalescing{ + StrictCoalescing, + PedanticCoalescing, + HumaneCoalescing, +} + +type VariableSource string + +func (c VariableSource) String() string { + return string(c) +} + +func (c VariableSource) IsValid() bool { + for _, coal := range AllVariableSources { + if coal == c { + return true + } + } + + return false +} + +const ( + StringVariableSource VariableSource = "string" + FileVariableSource VariableSource = "file" + EnvironmentVariableSource VariableSource = "env" +) + +var AllVariableSources = []VariableSource{ + StringVariableSource, + FileVariableSource, + EnvironmentVariableSource, +} diff --git a/cmd/rudi/types/options.go b/cmd/rudi/types/options.go deleted file mode 100644 index e945a1e..0000000 --- a/cmd/rudi/types/options.go +++ /dev/null @@ -1,86 +0,0 @@ -// SPDX-FileCopyrightText: 2023 Christoph Mewes -// SPDX-License-Identifier: MIT - -package types - -import ( - "errors" - - "github.com/spf13/pflag" -) - -// These constants must be lowercased because the validation function normalizes the given user -// input to lowercase. - -type Encoding string - -func (f Encoding) String() string { - return string(f) -} - -const ( - JsonEncoding Encoding = "json" - YamlEncoding Encoding = "yaml" - TomlEncoding Encoding = "toml" -) - -type Coalescer string - -func (c Coalescer) String() string { - return string(c) -} - -const ( - StrictCoalescer Coalescer = "strict" - PedanticCoalescer Coalescer = "pedantic" - HumaneCoalescer Coalescer = "humane" -) - -type Options struct { - ShowHelp bool - Interactive bool - ScriptFile string - StdinFormat Encoding - OutputFormat Encoding - PrintAst bool - ShowVersion bool - Coalescing Coalescer - EnableRudispaceFunctions bool -} - -func NewDefaultOptions() Options { - return Options{ - Coalescing: StrictCoalescer, - StdinFormat: YamlEncoding, - } -} - -func (o *Options) AddFlags(fs *pflag.FlagSet) { - fs.SortFlags = false - - stdinFormatFlag := newEnumFlag(&o.StdinFormat, JsonEncoding, YamlEncoding, TomlEncoding) - outputFormatFlag := newEnumFlag(&o.OutputFormat, JsonEncoding, YamlEncoding, TomlEncoding) - coalescingFlag := newEnumFlag(&o.Coalescing, StrictCoalescer, PedanticCoalescer, HumaneCoalescer) - - fs.BoolVarP(&o.Interactive, "interactive", "i", o.Interactive, "Start an interactive REPL to run expressions.") - fs.StringVarP(&o.ScriptFile, "script", "s", o.ScriptFile, "Load Rudi script from file instead of first argument (only in non-interactive mode).") - stdinFormatFlag.Add(fs, "stdin-format", "f", "What data format is used for data provided on stdin") - outputFormatFlag.Add(fs, "output-format", "o", "What data format to use for outputting data (if not given, unformatted JSON is used)") - fs.BoolVar(&o.EnableRudispaceFunctions, "enable-funcs", o.EnableRudispaceFunctions, "Enable the func! function to allow defining new functions in Rudi code.") - coalescingFlag.Add(fs, "coalesce", "c", "Type conversion handling") - fs.BoolVarP(&o.ShowHelp, "help", "h", o.ShowHelp, "Show help and documentation.") - fs.BoolVarP(&o.ShowVersion, "version", "V", o.ShowVersion, "Show version and exit.") - fs.BoolVarP(&o.PrintAst, "debug-ast", "", o.PrintAst, "Output syntax tree of the parsed script in non-interactive mode.") -} - -func (o *Options) Validate() error { - if o.Interactive && o.ScriptFile != "" { - return errors.New("cannot combine --interactive with --script") - } - - if o.Interactive && o.PrintAst { - return errors.New("cannot combine --interactive with --debug-ast") - } - - return nil -} diff --git a/cmd/rudi/util/context.go b/cmd/rudi/util/context.go index 7292119..945b2e3 100644 --- a/cmd/rudi/util/context.go +++ b/cmd/rudi/util/context.go @@ -8,11 +8,12 @@ import ( "go.xrstf.de/rudi" "go.xrstf.de/rudi/cmd/rudi/batteries" + "go.xrstf.de/rudi/cmd/rudi/options" "go.xrstf.de/rudi/cmd/rudi/types" "go.xrstf.de/rudi/pkg/coalescing" ) -func SetupRudiContext(opts *types.Options, fileNames []string, fileContents []any) (rudi.Context, error) { +func SetupRudiContext(opts *options.Options, fileNames []string, fileContents []any) (rudi.Context, error) { var ( document rudi.Document err error @@ -27,17 +28,23 @@ func SetupRudiContext(opts *types.Options, fileNames []string, fileContents []an document, _ = rudi.NewDocument(nil) } - vars := rudi.NewVariables(). + vars := rudi.NewVariables() + for k, v := range opts.ExtraVariables { + vars.Set(k, v) + } + + // system-defined variables come last, so they override anything user specified + vars. Set("files", fileContents). Set("filenames", fileNames) var coalescer coalescing.Coalescer switch opts.Coalescing { - case types.StrictCoalescer: + case types.StrictCoalescing: coalescer = coalescing.NewStrict() - case types.PedanticCoalescer: + case types.PedanticCoalescing: coalescer = coalescing.NewPedantic() - case types.HumaneCoalescer: + case types.HumaneCoalescing: coalescer = coalescing.NewHumane() default: return rudi.Context{}, fmt.Errorf("unknown coalescing mode %q", opts.Coalescing) diff --git a/cmd/rudi/util/files.go b/cmd/rudi/util/files.go index 74727e3..703f08d 100644 --- a/cmd/rudi/util/files.go +++ b/cmd/rudi/util/files.go @@ -6,18 +6,16 @@ package util import ( "errors" "fmt" - "io" "os" "path/filepath" "strings" + "go.xrstf.de/rudi/cmd/rudi/encoding" + "go.xrstf.de/rudi/cmd/rudi/options" "go.xrstf.de/rudi/cmd/rudi/types" - - "github.com/BurntSushi/toml" - "gopkg.in/yaml.v3" ) -func LoadFiles(opts *types.Options, filenames []string) ([]any, error) { +func LoadFiles(opts *options.Options, filenames []string) ([]any, error) { results := make([]any, len(filenames)) for i, filename := range filenames { @@ -32,54 +30,31 @@ func LoadFiles(opts *types.Options, filenames []string) ([]any, error) { return results, nil } -func LoadFile(opts *types.Options, filename string) (any, error) { +func LoadFile(opts *options.Options, filename string) (any, error) { if filename == "" { return nil, errors.New("no filename provided") } - var ( - input io.Reader - format types.Encoding - ) - if filename == "-" { - input = os.Stdin - format = opts.StdinFormat - } else { - switch strings.ToLower(filepath.Ext(filename)) { - case ".tml", ".toml": - format = types.TomlEncoding - default: - format = types.YamlEncoding - } - - f, err := os.Open(filename) - if err != nil { - return nil, err - } - defer f.Close() - - input = f + return encoding.Decode(os.Stdin, opts.StdinFormat) } - var doc any - - switch format { - case types.YamlEncoding: - decoder := yaml.NewDecoder(input) - if err := decoder.Decode(&doc); err != nil { - return nil, fmt.Errorf("failed to parse file as YAML/JSON: %w", err) - } + f, err := os.Open(filename) + if err != nil { + return nil, err + } + defer f.Close() - case types.TomlEncoding: - decoder := toml.NewDecoder(input) - if _, err := decoder.Decode(&doc); err != nil { - return nil, fmt.Errorf("failed to parse file as TOML: %w", err) - } + return encoding.Decode(f, getFileFormat(filename)) +} +func getFileFormat(filename string) types.Encoding { + switch strings.ToLower(filepath.Ext(filename)) { + case ".json": + return types.JsonEncoding + case ".tml", ".toml": + return types.TomlEncoding default: - return nil, fmt.Errorf("unexpected format %q", format) + return types.YamlEncoding } - - return doc, nil }