From 147854fe649249563d2d37cdc1782093224ba49e Mon Sep 17 00:00:00 2001 From: Yevgeniy Brikman Date: Fri, 16 Sep 2016 22:39:06 +0100 Subject: [PATCH 1/7] Basic RDD for adding types to boilerplate --- _docs/README.md | 99 ++++++++----------- config/config_test.go | 14 +-- .../dependencies-recursive/boilerplate.yml | 14 +-- examples/dependencies/boilerplate.yml | 14 +-- examples/docs/boilerplate.yml | 4 +- examples/website/boilerplate.yml | 5 +- .../config-test/full-config/boilerplate.yml | 8 +- 7 files changed, 73 insertions(+), 85 deletions(-) diff --git a/_docs/README.md b/_docs/README.md index 6ba879c8..7e5cc23a 100644 --- a/_docs/README.md +++ b/_docs/README.md @@ -23,7 +23,7 @@ Create a folder called `website-boilerplate` and put a file called `boilerplate. ``` This file defines 3 variables: `Title`, `WelcomeText`, and `ShowLogo`. When you run Boilerplate, it will prompt -the user (with the specified `prompt`, if specified) for each one. +the user for each one. Next, create an `index.html` in the `website-boilerplate` folder that uses these variables using [Go Template](https://golang.org/pkg/text/template) syntax: @@ -40,9 +40,19 @@ where you want the generated code to go: ``` boilerplate --template-folder /home/ubuntu/website-boilerplate --output-folder /home/ubuntu/website-output -Enter the value for 'Title': Boilerplate Example -Enter the welcome text for the website: Welcome! -Should the website show the logo? (y/n) (default: "y"): y +Title + + Enter a value [type: string]: Boilerplate Example + +WelcomeText + Enter the welcome text for the website + + Enter a value [type: string]: Welcome! + +ShowLogo + Should the website show the logo? + + Enter a [type: bool]: true Generating /home/ubuntu/website-output/index.html Copying /home/ubuntu/website-output/logo.png @@ -66,7 +76,7 @@ boilerplate \ --non-interactive \ --var Title="Boilerplate Example" \ --var WelcomeText="Welcome!" \ - --var ShowLogo="y" + --var ShowLogo="true" Generating /home/ubuntu/website-output/index.html Copying /home/ubuntu/website-output/logo.png @@ -91,6 +101,8 @@ You can find older versions on the [Releases Page](https://github.com/gruntwork- 1. **Flexible templating**: Boilerplate uses [Go Template](https://golang.org/pkg/text/template) for templating, which gives you the ability to do formatting, conditionals, loops, and call out to Go functions. It also includes helpers for common tasks such as loading the contents of another file. +1. **Variable types**: Boilerplate variables support types, so you have first-class support for strings, ints, bools, + lists, maps, and enums. 1. **Cross-platform**: Boilerplate is easy to install (it's a standalone binary) and works on all major platforms (Mac, Linux, Windows). @@ -164,7 +176,11 @@ variables or dependencies will be available. ```yaml variables: - name: - prompt: + description: + type: + options: + - + - default: dependencies: @@ -174,41 +190,26 @@ dependencies: dont-inherit-variables: variables: - name: - prompt: + description: + type: default: ``` Here's an example: ```yaml -variables: - - name: Title - prompt: Enter a title for the home page - - - name: IncludeLogo - prompt: Should we include a logo on the website? - default: true - - - name: Description - prompt: Enter a description for the home page - default: Welcome to my home page! - -dependencies: - - name: about - template-folder: ../about-us-page - output-folder: ../about-us-page - variables: - - name: Description - prompt: Enter a description for the about page - default: About Us +{{snippet "../examples/dependencies/boilerplate.yml"}} ``` **Variables**: A list of objects (i.e. dictionaries) that define variables. Each variable may contain the following keys: * `name` (Required): The name of the variable. -* `prompt` (Optional): The prompt to display to the user when asking them for a value. Default: - "Enter a value for ". +* `description` (Optional): The description of the variable. `boilerplate` will show this description to the user when + prompting them for a value. +* `type` (Optional): The type of the variable. Must be one of: `string`, `int`, `float`, `bool`, `map`, `list`, `enum`. + If unspecified, the default is `string`. +* `options` (Optional): If the `type` is `enum`, you can specify a list of valid options. Each option must be a string. * `default` (Optional): A default value for this variable. The user can just hit ENTER at the command line to use the default value, if one is provided. If running Boilerplate with the `--non-interactive` flag, the default is used for this value if no value is provided via the `--var` or `--var-file` options. @@ -229,7 +230,7 @@ executing the current one. Each dependency may contain the following keys: * `variables`: If a dependency contains a variable of the same name as a variable in the root `boilerplate.yml` file, but you want the dependency to get a different value for the variable, you can specify overrides here. `boilerplate` will include a separate prompt for variables defined under a `dependency`. You can also override the dependency's - prompt and default values here. + description and default values here. See the [Dependencies](#dependencies) section for more info. @@ -239,8 +240,10 @@ You must provide a value for every variable defined in `boilerplate.yml`, or pro four ways to provide a value for a variable: 1. `--var` option(s) you pass in when calling boilerplate. Example: - `boilerplate --var Title=Boilerplate --var ShowLogo=false`. If you want to specify the value of a variable for a - specific dependency, use the `.` syntax. For example: + `boilerplate --var Title=Boilerplate --var ShowLogo=false`. To specify a complex type like a map or a list on the + command-line, use YAML syntax (preferably the shorthand variety to keep it a one-liner). For example + `--var foo='{key: "value"}' --var bar='["a", "b", "c"]'`. If you want to specify the value of a + variable for a specific dependency, use the `.` syntax. For example: `boilerplate --var Description='Welcome to my home page!' --var about.Description='About Us' --var ShowLogo=false`. 1. `--var-file` option(s) you pass in when calling boilerplate. Example: `boilerplate --var-file vars.yml`. The vars file must be a simple YAML file that defines key, value pairs, where the key is the name of a variable (or @@ -252,6 +255,12 @@ four ways to provide a value for a variable: ShowLogo: false Description: Welcome to my home page! about.Description: Welcome to my home page! + ExampleOfAMap: + key1: value1 + key2: value2 + ExampleOfAList: + - value1 + - value2 ``` 1. Manual input. If no value is specified via the `--var` or `--var-file` flags, Boilerplate will interactively prompt the user to provide a value. Note that the `--non-interactive` flag disables this functionality. @@ -285,7 +294,8 @@ non-binary file through the [Go Template](https://golang.org/pkg/text/template) data structure. For example, if you had a variable called `Title` in your `boilerplate.yml` file, then you could access that variable -in any of your templates using the syntax `{{"{{"}}.Title{{"}}"}}`. +in any of your templates using the syntax `{{"{{"}}.Title{{"}}"}}`. You can also use Go template syntax to do +if-statements, for loops, and use the provided [template helpers](#template-helpers). You can even use Go template syntax and boilerplate variables in the names of your files and folders. For example, if you were using `boilerplate` to generate a Java project, your template folder could contain the path @@ -361,26 +371,3 @@ inspiring many of the ideas in Boilerplate and so you can try out other projects * [play-doc](https://github.com/playframework/play-doc): Documentation generator used by the Play Framework that allows code snippets to be loaded from external files. Great for ensuring the code snippets in your docs are from files that are compiled and tested, but does not work as a general-purpose project generator. - -## TODO - -1. Add support for using Go template syntax in the paths of files so they can be copied dynamically. -1. Add support for callbacks. For example, a `start` callback called just after Boilerplate gathers all variables but - before it starts generating; an `file` callback called for each file and folder Boilerplate processes; and an - `end` callback called after project generation is finished. The callbacks could be defined as simple shell - commands in `boilerplate.yml`: - - ```yaml - variables: - - name: foo - - name: bar - - callbacks: - start: echo "Boilerplate is generating files to $2 from templates in $1" - file: echo "Boilerplate is processing file $1" - end: echo "Boilerplate finished generating files in $2 from templates in $1" - ``` -1. Support a list of `ignore` files in `boilerplate.yml` or even a `.boilerplate-ignore` file for files that should be - skipped over while generating code. -1. Consider supporting different types for variables. Currently, all variables are strings, but there may be value - in specifying a `type` in `boilerplate.yml`. Useful types: string, int, bool, float, list, map. \ No newline at end of file diff --git a/config/config_test.go b/config/config_test.go index a9564d47..4ddcc161 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -67,7 +67,7 @@ func TestParseBoilerplateConfigOneVariableMinimal(t *testing.T) { const CONFIG_ONE_VARIABLE_FULL = `variables: - name: foo - prompt: prompt + description: prompt default: default ` @@ -88,7 +88,7 @@ func TestParseBoilerplateConfigOneVariableFull(t *testing.T) { // YAML is whitespace sensitive, so we need to be careful that we don't introduce unnecessary indentation const CONFIG_ONE_VARIABLE_MISSING_NAME = `variables: - - prompt: prompt + - description: prompt default: default ` @@ -107,14 +107,14 @@ const CONFIG_MULTIPLE_VARIABLES = - name: foo - name: bar - prompt: prompt + description: prompt - name: baz - prompt: prompt + description: prompt default: default - name: dep1.baz - prompt: another-prompt + description: another-prompt default: another-default ` @@ -170,7 +170,7 @@ const CONFIG_MULTIPLE_DEPENDENCIES = dont-inherit-variables: true variables: - name: var1 - prompt: Enter var1 + description: Enter var1 default: foo - name: dep3 @@ -249,7 +249,7 @@ const CONFIG_DEPENDENCY_MISSING_VARIABLE_NAME = template-folder: /template/folder1 output-folder: /output/folder1 variables: - - prompt: Enter foo + - description: Enter foo default: foo ` diff --git a/examples/dependencies-recursive/boilerplate.yml b/examples/dependencies-recursive/boilerplate.yml index de3024fb..0f8aac43 100644 --- a/examples/dependencies-recursive/boilerplate.yml +++ b/examples/dependencies-recursive/boilerplate.yml @@ -1,18 +1,18 @@ variables: - name: Description - prompt: Enter the description of the recursive dependencies template + description: Enter the description of the recursive dependencies template - name: Version - prompt: Enter the version number that will be used by the docs dependency + description: Enter the version number that will be used by the docs dependency - name: Title - prompt: Enter the title for the dependencies recursive example + description: Enter the title for the dependencies recursive example - name: WelcomeText - prompt: Enter the welcome text used by the website dependency + description: Enter the welcome text used by the website dependency - name: ShowLogo - prompt: Should the webiste show the logo (true or false)? + description: Should the webiste show the logo (true or false)? default: true dependencies: @@ -21,7 +21,7 @@ dependencies: output-folder: ./dependencies variables: - name: Description - prompt: Enter the description of the dependencies example + description: Enter the description of the dependencies example - name: Title - prompt: Enter the title for the dependencies example + description: Enter the title for the dependencies example diff --git a/examples/dependencies/boilerplate.yml b/examples/dependencies/boilerplate.yml index 964016b2..af20bb48 100644 --- a/examples/dependencies/boilerplate.yml +++ b/examples/dependencies/boilerplate.yml @@ -1,18 +1,18 @@ variables: - name: Description - prompt: Enter the description of this template + description: Enter the description of this template - name: Version - prompt: Enter the version number that will be used by the docs dependency + description: Enter the version number that will be used by the docs dependency - name: Title - prompt: Enter the title for the dependencies example + description: Enter the title for the dependencies example - name: WelcomeText - prompt: Enter the welcome text used by the website dependency + description: Enter the welcome text used by the website dependency - name: ShowLogo - prompt: Should the webiste show the logo (true or false)? + description: Should the webiste show the logo (true or false)? default: true dependencies: @@ -21,11 +21,11 @@ dependencies: output-folder: ./docs variables: - name: Title - prompt: Enter the title of the docs page + description: Enter the title of the docs page - name: website template-folder: ../website output-folder: ./website variables: - name: Title - prompt: Enter the title of the website \ No newline at end of file + description: Enter the title of the website \ No newline at end of file diff --git a/examples/docs/boilerplate.yml b/examples/docs/boilerplate.yml index a9ed1452..d5ee4076 100644 --- a/examples/docs/boilerplate.yml +++ b/examples/docs/boilerplate.yml @@ -4,9 +4,9 @@ variables: - name: Version - name: SubFolderName - prompt: This variable will be used to create the name of a subfolder dynamically + description: This variable will be used to create the name of a subfolder dynamically default: example folder - name: FileName - prompt: This variable will be used to create the name of a file dynamically + description: This variable will be used to create the name of a file dynamically default: my example file \ No newline at end of file diff --git a/examples/website/boilerplate.yml b/examples/website/boilerplate.yml index ecf4684e..61608a8b 100644 --- a/examples/website/boilerplate.yml +++ b/examples/website/boilerplate.yml @@ -2,8 +2,9 @@ variables: - name: Title - name: WelcomeText - prompt: Enter the welcome text for the website + description: Enter the welcome text for the website - name: ShowLogo - prompt: Should the webiste show the logo (true or false)? + description: Should the website show the logo? + type: bool default: true diff --git a/test-fixtures/config-test/full-config/boilerplate.yml b/test-fixtures/config-test/full-config/boilerplate.yml index 8af00ea5..a2508a30 100644 --- a/test-fixtures/config-test/full-config/boilerplate.yml +++ b/test-fixtures/config-test/full-config/boilerplate.yml @@ -2,10 +2,10 @@ variables: - name: foo - name: bar - prompt: prompt + description: prompt - name: baz - prompt: prompt + description: prompt default: default dependencies: @@ -19,10 +19,10 @@ dependencies: dont-inherit-variables: true variables: - name: baz - prompt: prompt + description: prompt default: other-default - name: abc - prompt: prompt + description: prompt default: default From 009258b077fac3c7ad928348beb3c134d9f29044 Mon Sep 17 00:00:00 2001 From: Yevgeniy Brikman Date: Sun, 18 Sep 2016 23:08:58 +0100 Subject: [PATCH 2/7] Initial refactor to support types --- cli/boilerplate_cli.go | 4 +- config/config.go | 64 ++-- config/config_test.go | 175 ++++++++-- config/types.go | 36 ++ config/variables.go | 309 ++++++++++++++++-- config/variables_test.go | 58 ++-- errors/errors.go | 2 +- .../dependencies-recursive/boilerplate.yml | 1 + examples/dependencies/boilerplate.yml | 1 + examples/website/index.html | 2 +- templates/template_processor.go | 30 +- templates/template_processor_test.go | 113 +++---- .../config-test/full-config/boilerplate.yml | 8 +- .../dependencies/docs/README.md | 2 +- .../dependencies/docs/README.md | 2 +- .../examples-expected-output/docs/README.md | 2 +- util/collections.go | 6 +- 17 files changed, 607 insertions(+), 208 deletions(-) create mode 100644 config/types.go diff --git a/cli/boilerplate_cli.go b/cli/boilerplate_cli.go index 23a3d923..32e63a5b 100644 --- a/cli/boilerplate_cli.go +++ b/cli/boilerplate_cli.go @@ -147,8 +147,8 @@ func parseOptions(cliContext *cli.Context) (*config.BoilerplateOptions, error) { // Parse variables passed in via command line flags, either as a list of NAME=VALUE variable pairs in varsList, or a // list of paths to YAML files that define NAME: VALUE pairs. Return a map of the NAME: VALUE pairs. -func parseVars(varsList []string, varFileList[]string) (map[string]string, error) { - variables := map[string]string{} +func parseVars(varsList []string, varFileList[]string) (map[string]interface{}, error) { + variables := map[string]interface{}{} varsFromVarsList, err := config.ParseVariablesFromKeyValuePairs(varsList) if err != nil { diff --git a/config/config.go b/config/config.go index 2999a924..750b55ac 100644 --- a/config/config.go +++ b/config/config.go @@ -25,7 +25,7 @@ type BoilerplateOptions struct { TemplateFolder string OutputFolder string NonInteractive bool - Vars map[string]string + Vars map[string]interface{} OnMissingKey MissingKeyAction OnMissingConfig MissingConfigAction } @@ -97,24 +97,6 @@ type BoilerplateConfig struct { Dependencies []Dependency } -// A single variable defined in a boilerplate.yml config file -type Variable struct { - Name string - Prompt string - Default string -} - -// Return a description of this variable, which includes its name and the dependency it is for (if any) in a -// human-readable format -func (variable Variable) Description() string { - dependencyName, variableName := SplitIntoDependencyNameAndVariableName(variable.Name) - if dependencyName == "" { - return variableName - } else { - return fmt.Sprintf("%s (for dependency %s)", variableName, dependencyName) - } -} - // Given a unique variable name, return a tuple that contains the dependency name (if any) and the variable name. // Variable and dependency names are split by a dot, so for "foo.bar", this will return ("foo", "bar"). For just "foo", // it will return ("", "foo"). @@ -179,10 +161,6 @@ func ParseBoilerplateConfig(configContents []byte) (*BoilerplateConfig, error) { // Validate that the config file has reasonable contents and return an error if there is a problem func (boilerplateConfig BoilerplateConfig) validate() error { - if err := validateVariables(boilerplateConfig.Variables); err != nil { - return err - } - if err := validateDependencies(boilerplateConfig.Dependencies); err != nil { return err } @@ -190,17 +168,6 @@ func (boilerplateConfig BoilerplateConfig) validate() error { return nil } -// Validate that the list of variables has reasonable contents and return an error if there is a problem -func validateVariables(variables []Variable) error { - for _, variable := range variables { - if variable.Name == "" { - return errors.WithStackTrace(VariableMissingName) - } - } - - return nil -} - // Validate that the list of dependencies has reasonable contents and return an error if there is a problem func validateDependencies(dependencies []Dependency) error { dependencyNames := []string{} @@ -219,10 +186,6 @@ func validateDependencies(dependencies []Dependency) error { if dependency.OutputFolder == "" { return errors.WithStackTrace(MissingOutputFolderForDependency(dependency.Name)) } - - if err := validateVariables(dependency.Variables); err != nil { - return err - } } return nil @@ -230,12 +193,28 @@ func validateDependencies(dependencies []Dependency) error { // Custom error types -var VariableMissingName = fmt.Errorf("Error: found a variable without a name.") - var TemplateFolderOptionCannotBeEmpty = fmt.Errorf("The --%s option cannot be empty", OPT_TEMPLATE_FOLDER) var OutputFolderOptionCannotBeEmpty = fmt.Errorf("The --%s option cannot be empty", OPT_OUTPUT_FOLDER) +type RequiredFieldMissing string +func (err RequiredFieldMissing) Error() string { + return fmt.Sprintf("Variable is missing required field %s", string(err)) +} + +type VariableMissingOptions string +func (err VariableMissingOptions) Error() string { + return fmt.Sprintf("Variable %s has type %s but does not specify any options. You must specify at least one option.", string(err), Enum) +} + +type OptionsCanOnlyBeUsedWithEnum struct { + VariableName string + VariableType BoilerplateType +} +func (err OptionsCanOnlyBeUsedWithEnum) Error() string { + return fmt.Sprintf("Variable %s has type %s and tries to specify options. Options may only be specified for variables of type %s.", err.VariableName, err.VariableType.String(), Enum) +} + type TemplateFolderDoesNotExist string func (err TemplateFolderDoesNotExist) Error() string { return fmt.Sprintf("Folder %s does not exist", string(err)) @@ -251,6 +230,11 @@ func (err InvalidMissingConfigAction) Error() string { return fmt.Sprintf("Invalid MissingConfigAction '%s'. Value must be one of: %s", string(err), ALL_MISSING_CONFIG_ACTIONS) } +type InvalidBoilerplateType string +func (err InvalidBoilerplateType) Error() string { + return fmt.Sprintf("Invalid InvalidBoilerplateType '%s'. Value must be one of: %s", string(err), ALL_BOILERPLATE_TYPES) +} + type BoilerplateConfigNotFound string func (err BoilerplateConfigNotFound) Error() string { return fmt.Sprintf("Could not find %s in %s and the %s flag is set to %s", BOILERPLATE_CONFIG_FILE, string(err), OPT_MISSING_CONFIG_ACTION, Exit) diff --git a/config/config_test.go b/config/config_test.go index 4ddcc161..554e05a7 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -55,7 +55,7 @@ func TestParseBoilerplateConfigOneVariableMinimal(t *testing.T) { actual, err := ParseBoilerplateConfig([]byte(CONFIG_ONE_VARIABLE_MINIMAL)) expected := &BoilerplateConfig{ Variables: []Variable{ - Variable{Name: "foo"}, + {Name: "foo", Type: String}, }, } @@ -67,7 +67,8 @@ func TestParseBoilerplateConfigOneVariableMinimal(t *testing.T) { const CONFIG_ONE_VARIABLE_FULL = `variables: - name: foo - description: prompt + description: example description + type: string default: default ` @@ -77,7 +78,7 @@ func TestParseBoilerplateConfigOneVariableFull(t *testing.T) { actual, err := ParseBoilerplateConfig([]byte(CONFIG_ONE_VARIABLE_FULL)) expected := &BoilerplateConfig{ Variables: []Variable{ - Variable{Name: "foo", Prompt: "prompt", Default: "default"}, + {Name: "foo", Description: "example description", Default: "default", Type: String}, }, } @@ -88,7 +89,7 @@ func TestParseBoilerplateConfigOneVariableFull(t *testing.T) { // YAML is whitespace sensitive, so we need to be careful that we don't introduce unnecessary indentation const CONFIG_ONE_VARIABLE_MISSING_NAME = `variables: - - description: prompt + - description: example description default: default ` @@ -98,7 +99,57 @@ func TestParseBoilerplateConfigOneVariableMissingName(t *testing.T) { _, err := ParseBoilerplateConfig([]byte(CONFIG_ONE_VARIABLE_MISSING_NAME)) assert.NotNil(t, err) - assert.True(t, errors.IsError(err, VariableMissingName), "Expected a VariableMissingName error but got %s", reflect.TypeOf(err)) + assert.True(t, errors.IsError(err, RequiredFieldMissing("name")), "Expected a RequiredFieldMissing error but got %s", reflect.TypeOf(err)) +} + +// YAML is whitespace sensitive, so we need to be careful that we don't introduce unnecessary indentation +const CONFIG_ONE_VARIABLE_INVALID_TYPE = +`variables: + - name: foo + type: foo +` + +func TestParseBoilerplateConfigOneVariableInvalidType(t *testing.T) { + t.Parallel() + + _, err := ParseBoilerplateConfig([]byte(CONFIG_ONE_VARIABLE_INVALID_TYPE)) + + assert.NotNil(t, err) + assert.True(t, errors.IsError(err, InvalidBoilerplateType("foo")), "Expected a InvalidBoilerplateType error but got %s", reflect.TypeOf(err)) +} + +// YAML is whitespace sensitive, so we need to be careful that we don't introduce unnecessary indentation +const CONFIG_ONE_VARIABLE_ENUM_NO_OPTIONS = +`variables: + - name: foo + type: enum +` + +func TestParseBoilerplateConfigOneVariableEnumNoOptions(t *testing.T) { + t.Parallel() + + _, err := ParseBoilerplateConfig([]byte(CONFIG_ONE_VARIABLE_ENUM_NO_OPTIONS)) + + assert.NotNil(t, err) + assert.True(t, errors.IsError(err, VariableMissingOptions("foo")), "Expected a VariableMissingOptions error but got %s", reflect.TypeOf(err)) +} + +// YAML is whitespace sensitive, so we need to be careful that we don't introduce unnecessary indentation +const CONFIG_ONE_VARIABLE_OPTIONS_FOR_NON_ENUM = +`variables: + - name: foo + options: + - foo + - bar +` + +func TestParseBoilerplateConfigOneVariableOptionsForNonEnum(t *testing.T) { + t.Parallel() + + _, err := ParseBoilerplateConfig([]byte(CONFIG_ONE_VARIABLE_OPTIONS_FOR_NON_ENUM)) + + assert.NotNil(t, err) + assert.True(t, errors.IsError(err, OptionsCanOnlyBeUsedWithEnum{VariableName: "foo", VariableType: String}), "Expected a OptionsCanOnlyBeUsedWithEnum error but got %v", err) } // YAML is whitespace sensitive, so we need to be careful that we don't introduce unnecessary indentation @@ -107,15 +158,17 @@ const CONFIG_MULTIPLE_VARIABLES = - name: foo - name: bar - description: prompt + description: example description - name: baz - description: prompt - default: default + description: example description + type: int + default: 3 - name: dep1.baz - description: another-prompt - default: another-default + description: another example description + type: bool + default: true ` func TestParseBoilerplateConfigMultipleVariables(t *testing.T) { @@ -124,10 +177,76 @@ func TestParseBoilerplateConfigMultipleVariables(t *testing.T) { actual, err := ParseBoilerplateConfig([]byte(CONFIG_MULTIPLE_VARIABLES)) expected := &BoilerplateConfig{ Variables: []Variable{ - Variable{Name: "foo"}, - Variable{Name: "bar", Prompt: "prompt"}, - Variable{Name: "baz", Prompt: "prompt", Default: "default"}, - Variable{Name: "dep1.baz", Prompt: "another-prompt", Default: "another-default"}, + {Name: "foo", Type: String}, + {Name: "bar", Description: "example description", Type: String}, + {Name: "baz", Description: "example description", Type: Int, Default: 3}, + {Name: "dep1.baz", Description: "another example description", Type: Bool, Default: true}, + }, + } + + assert.Nil(t, err) + assert.Equal(t, expected, actual) +} + +// YAML is whitespace sensitive, so we need to be careful that we don't introduce unnecessary indentation +const CONFIG_ALL_TYPES = +`variables: + - name: var1 + default: foo + + - name: var2 + type: string + default: foo + + - name: var3 + type: int + default: 5 + + - name: var4 + type: float + default: 5.5 + + - name: var5 + type: bool + default: true + + - name: var6 + type: list + default: + - foo + - bar + - baz + + - name: var7 + type: map + default: + key1: value1 + key2: value2 + key3: value3 + + - name: var8 + type: enum + options: + - foo + - bar + - baz + default: bar +` + +func TestParseBoilerplateConfigAllTypes(t *testing.T) { + t.Parallel() + + actual, err := ParseBoilerplateConfig([]byte(CONFIG_ALL_TYPES)) + expected := &BoilerplateConfig{ + Variables: []Variable{ + {Name: "var1", Type: String, Default: "foo"}, + {Name: "var2", Type: String, Default: "foo"}, + {Name: "var3", Type: Int, Default: 5}, + {Name: "var4", Type: Float, Default: 5.5}, + {Name: "var5", Type: Bool, Default: true}, + {Name: "var6", Type: List, Default: []string{"foo", "bar", "baz"}}, + {Name: "var7", Type: Map, Default: map[string]string{"key1": "value1", "key2": "value2", "key3": "value3"}}, + {Name: "var8", Type: Enum, Default: "bar", Options: []string{"foo", "bar", "baz"}}, }, } @@ -149,7 +268,7 @@ func TestParseBoilerplateConfigOneDependency(t *testing.T) { actual, err := ParseBoilerplateConfig([]byte(CONFIG_ONE_DEPENDENCY)) expected := &BoilerplateConfig{ Dependencies: []Dependency{ - Dependency{Name: "dep1", TemplateFolder: "/template/folder1", OutputFolder: "/output/folder1", DontInheritVariables: false}, + {Name: "dep1", TemplateFolder: "/template/folder1", OutputFolder: "/output/folder1", DontInheritVariables: false}, }, } @@ -184,20 +303,22 @@ func TestParseBoilerplateConfigMultipleDependencies(t *testing.T) { actual, err := ParseBoilerplateConfig([]byte(CONFIG_MULTIPLE_DEPENDENCIES)) expected := &BoilerplateConfig{ Dependencies: []Dependency{ - Dependency{ + { Name: "dep1", TemplateFolder: "/template/folder1", OutputFolder: "/output/folder1", DontInheritVariables: false, }, - Dependency{ + { Name: "dep2", TemplateFolder: "/template/folder2", OutputFolder: "/output/folder2", DontInheritVariables: true, - Variables: []Variable{Variable{Name: "var1", Prompt: "Enter var1", Default: "foo"}}, + Variables: []Variable{ + {Name: "var1", Description: "Enter var1", Default: "foo", Type: String}, + }, }, - Dependency{ + { Name: "dep3", TemplateFolder: "/template/folder3", OutputFolder: "/output/folder3", @@ -259,7 +380,7 @@ func TestParseBoilerplateConfigDependencyMissingVariableName(t *testing.T) { _, err := ParseBoilerplateConfig([]byte(CONFIG_DEPENDENCY_MISSING_VARIABLE_NAME)) assert.NotNil(t, err) - assert.True(t, errors.IsError(err, VariableMissingName), "Expected a VariableMissingName error but got %s", reflect.TypeOf(err)) + assert.True(t, errors.IsError(err, RequiredFieldMissing("name")), "Expected a RequiredFieldMissing error but got %s", reflect.TypeOf(err)) } // YAML is whitespace sensitive, so we need to be careful that we don't introduce unnecessary indentation @@ -313,15 +434,15 @@ func TestLoadBoilerplateConfigFullConfig(t *testing.T) { actual, err := LoadBoilerplateConfig(&BoilerplateOptions{TemplateFolder: "../test-fixtures/config-test/full-config"}) expected := &BoilerplateConfig{ Variables: []Variable{ - Variable{Name: "foo"}, - Variable{Name: "bar", Prompt: "prompt"}, - Variable{Name: "baz", Prompt: "prompt", Default: "default"}, + {Name: "foo", Type: String}, + {Name: "bar", Type: String, Description: "example description"}, + {Name: "baz", Type: String, Description: "example description", Default: "default"}, }, Dependencies: []Dependency{ - Dependency{Name: "dep1", TemplateFolder: "/template/folder1", OutputFolder: "/output/folder1", DontInheritVariables: false}, - Dependency{Name: "dep2", TemplateFolder: "/template/folder2", OutputFolder: "/output/folder2", DontInheritVariables: true, Variables: []Variable{ - Variable{Name: "baz", Prompt: "prompt", Default: "other-default"}, - Variable{Name: "abc", Prompt: "prompt", Default: "default"}, + {Name: "dep1", TemplateFolder: "/template/folder1", OutputFolder: "/output/folder1", DontInheritVariables: false}, + {Name: "dep2", TemplateFolder: "/template/folder2", OutputFolder: "/output/folder2", DontInheritVariables: true, Variables: []Variable{ + {Name: "baz", Type: String, Description: "example description", Default: "other-default"}, + {Name: "abc", Type: String, Description: "example description", Default: "default"}, }}, }, } diff --git a/config/types.go b/config/types.go new file mode 100644 index 00000000..d961655d --- /dev/null +++ b/config/types.go @@ -0,0 +1,36 @@ +package config + +import ( + "github.com/gruntwork-io/boilerplate/errors" +) + +// An enum that represents the types we support for boilerplate variables +type BoilerplateType string + +var ( + String = BoilerplateType("string") + Int = BoilerplateType("int") + Float = BoilerplateType("float") + Bool = BoilerplateType("bool") + List = BoilerplateType("list") + Map = BoilerplateType("map") + Enum = BoilerplateType("enum") +) + +var ALL_BOILERPLATE_TYPES = []BoilerplateType{String, Int, Float, Bool, List, Map, Enum} +var BOILERPLATE_TYPE_DEFAULT = String + +// Convert the given string to a BoilerplateType enum, or return an error if this is not a valid value for the +// BoilerplateType enum +func ParseBoilerplateType(str string) (*BoilerplateType, error) { + for _, boilerplateType := range ALL_BOILERPLATE_TYPES { + if boilerplateType.String() == str { + return &boilerplateType, nil + } + } + return nil, errors.WithStackTrace(InvalidBoilerplateType(str)) +} + +func (boilerplateType BoilerplateType) String() string { + return string(boilerplateType) +} \ No newline at end of file diff --git a/config/variables.go b/config/variables.go index 320c17a1..aa7e4bc9 100644 --- a/config/variables.go +++ b/config/variables.go @@ -7,13 +7,213 @@ import ( "strings" "io/ioutil" "gopkg.in/yaml.v2" + "reflect" ) +// A single variable defined in a boilerplate.yml config file +type Variable struct { + Name string + Description string + Type BoilerplateType + Default interface{} + Options []string +} + +// Return the full name of this variable, which includes its name and the dependency it is for (if any) in a +// human-readable format +func (variable Variable) FullName() string { + dependencyName, variableName := SplitIntoDependencyNameAndVariableName(variable.Name) + if dependencyName == "" { + return variableName + } else { + return fmt.Sprintf("%s (for dependency %s)", variableName, dependencyName) + } +} + +func (variable Variable) String() string { + return fmt.Sprintf("Variable {Name: '%s', Description: '%s', Type: '%s', Default: '%v', Options: '%v'}", variable.Name, variable.Description, variable.Type, variable.Default, variable.Options) +} + +// Implement the go-yaml unmarshal interface for Variable. We can't let go-yaml handle this itself because we need to: +// +// 1. Set Defaults for missing fields (e.g. Type) +// 2. Validate the type corresponds to the Default value +// 3. Validate Options are only specified for the Enum Type +func (variable *Variable) UnmarshalYAML(unmarshal func(interface{}) error) error { + var fields map[string]interface{} + if err := unmarshal(&fields); err != nil { + return err + } + + if unmarshalled, err := unmarshalVariable(fields); err != nil { + return err + } else { + *variable = *unmarshalled + return nil + } +} + +func unmarshalVariable(fields map[string]interface{}) (*Variable, error) { + variable := Variable{} + var err error + + variable.Name, err = unmarshalStringField(fields, "name", true, "") + if err != nil { + return nil, err + } + + variable.Description, err = unmarshalStringField(fields, "description", false, variable.Name) + if err != nil { + return nil, err + } + + variable.Type, err = unmarshalTypeField(fields, "type", variable.Name) + if err != nil { + return nil, err + } + + variable.Options, err = unmarshalOptionsField(fields, "options", variable.Name, variable.Type) + if err != nil { + return nil, err + } + + variable.Default, err = unmarshalValue(fields["default"], variable) + if err != nil { + return nil, err + } + + return &variable, nil +} + +func unmarshalValue(value interface{}, variable Variable) (interface{}, error) { + if value == nil { + return nil, nil + } + + switch variable.Type { + case String: + if asString, isString := value.(string); isString { + return asString, nil + } + case Int: + if asInt, isInt := value.(int); isInt { + return asInt, nil + } + case Float: + if asFloat, isFloat := value.(float64); isFloat { + return asFloat, nil + } + case Bool: + if asBool, isBool := value.(bool); isBool { + return asBool, nil + } + case List: + if asList, isList := value.([]interface{}); isList { + return toStringList(asList), nil + } + case Map: + if asMap, isMap := value.(map[interface{}]interface{}); isMap { + return toStringMap(asMap), nil + } + case Enum: + if asString, isString := value.(string); isString { + for _, option := range variable.Options { + if asString == option { + return asString, nil + } + } + } + } + + return nil, InvalidVariableValue{Variable: variable, Value: value} +} + +func unmarshalOptionsField(fields map[string]interface{}, fieldName string, variableName string, variableType BoilerplateType) ([]string, error) { + options, hasOptions := fields[fieldName] + + if !hasOptions { + if variableType == Enum { + return nil, errors.WithStackTrace(VariableMissingOptions(variableName)) + } else { + return nil, nil + } + } + + if variableType != Enum { + return nil, errors.WithStackTrace(OptionsCanOnlyBeUsedWithEnum{VariableName: variableName, VariableType: variableType}) + } + + optionsAsList, isList := options.([]interface{}) + if !isList { + return nil, errors.WithStackTrace(InvalidTypeForField{FieldName: fieldName, ExpectedType: "List", ActualType: reflect.TypeOf(options).String(), VariableName: variableName}) + } + + return toStringList(optionsAsList), nil +} + +func toStringList(genericList []interface{}) []string { + stringList := []string{} + + for _, value := range genericList { + stringList = append(stringList, toString(value)) + } + + return stringList +} + +func toStringMap(genericMap map[interface{}]interface{}) map[string]string { + stringMap := map[string]string{} + + for key, value := range genericMap { + stringMap[toString(key)] = toString(value) + } + + return stringMap +} + +func toString(value interface{}) string { + return fmt.Sprintf("%v", value) +} + +func unmarshalTypeField(fields map[string]interface{}, fieldName string, variableName string) (BoilerplateType, error) { + variableTypeAsString, err := unmarshalStringField(fields, fieldName, false, variableName) + if err != nil { + return BOILERPLATE_TYPE_DEFAULT, err + } + + if variableTypeAsString != "" { + variableType, err := ParseBoilerplateType(variableTypeAsString) + if err != nil { + return BOILERPLATE_TYPE_DEFAULT, err + } + return *variableType, nil + } + + return BOILERPLATE_TYPE_DEFAULT, nil +} + +func unmarshalStringField(fields map[string]interface{}, fieldName string, requiredField bool, variableName string) (string, error) { + value, hasValue := fields[fieldName] + if !hasValue { + if requiredField { + return "", errors.WithStackTrace(RequiredFieldMissing(fieldName)) + } else { + return "", nil + } + } + + if valueAsString, isString := value.(string); isString { + return valueAsString, nil + } else { + return "", errors.WithStackTrace(InvalidTypeForField{FieldName: fieldName, ExpectedType: "string", ActualType: reflect.TypeOf(value).String(), VariableName: variableName}) + } +} + // Get a value for each of the variables specified in boilerplateConfig, other than those already in existingVariables. // The value for a variable can come from the user (if the non-interactive option isn't set), the default value in the // config, or a command line option. -func GetVariables(options *BoilerplateOptions, boilerplateConfig *BoilerplateConfig) (map[string]string, error) { - variables := map[string]string{} +func GetVariables(options *BoilerplateOptions, boilerplateConfig *BoilerplateConfig) (map[string]interface{}, error) { + variables := map[string]interface{}{} for key, value := range options.Vars { variables[key] = value } @@ -21,12 +221,20 @@ func GetVariables(options *BoilerplateOptions, boilerplateConfig *BoilerplateCon variablesInConfig := getAllVariablesInConfig(boilerplateConfig) for _, variable := range variablesInConfig { - if _, alreadyExists := variables[variable.Name]; !alreadyExists { - value, err := getVariable(variable, options) + var value interface{} + var err error + + value, alreadyExists := variables[variable.Name] + if !alreadyExists { + value, err = getVariable(variable, options) if err != nil { return variables, err } - variables[variable.Name] = value + } + + variables[variable.Name], err = unmarshalValue(value, variable) + if err != nil { + return variables, err } } @@ -42,7 +250,7 @@ func getAllVariablesInConfig(boilerplateConfig *BoilerplateConfig) []Variable { for _, dependency := range boilerplateConfig.Dependencies { for _, variable := range dependency.Variables { variableName := fmt.Sprintf("%s.%s", dependency.Name, variable.Name) - allVariables = append(allVariables, Variable{Name: variableName, Prompt: variable.Prompt, Default: variable.Default}) + allVariables = append(allVariables, Variable{Name: variableName, Description: variable.Description, Type: variable.Type, Default: variable.Default, Options: variable.Options}) } } @@ -51,39 +259,39 @@ func getAllVariablesInConfig(boilerplateConfig *BoilerplateConfig) []Variable { // Get a value for the given variable. The value can come from the user (if the non-interactive option isn't set), the // default value in the config, or a command line option. -func getVariable(variable Variable, options *BoilerplateOptions) (string, error) { +func getVariable(variable Variable, options *BoilerplateOptions) (interface{}, error) { valueFromVars, valueSpecifiedInVars := getVariableFromVars(variable, options) if valueSpecifiedInVars { - util.Logger.Printf("Using value specified via command line options for variable '%s': %s", variable.Description(), valueFromVars) + util.Logger.Printf("Using value specified via command line options for variable '%s': %s", variable.FullName(), valueFromVars) return valueFromVars, nil - } else if options.NonInteractive && variable.Default != "" { + } else if options.NonInteractive && variable.Default != nil { // TODO: how to disambiguate between a default not being specified and a default set to an empty string? - util.Logger.Printf("Using default value for variable '%s': %s", variable.Description(), variable.Default) + util.Logger.Printf("Using default value for variable '%s': %v", variable.FullName(), variable.Default) return variable.Default, nil } else if options.NonInteractive { - return "", errors.WithStackTrace(MissingVariableWithNonInteractiveMode(variable.Description())) + return nil, errors.WithStackTrace(MissingVariableWithNonInteractiveMode(variable.FullName())) } else { return getVariableFromUser(variable, options) } } // Return the value of the given variable from vars passed in as command line options -func getVariableFromVars(variable Variable, options *BoilerplateOptions) (string, bool) { +func getVariableFromVars(variable Variable, options *BoilerplateOptions) (interface{}, bool) { for name, value := range options.Vars { if name == variable.Name { return value, true } } - return "", false + return nil, false } // Get the value for the given variable by prompting the user -func getVariableFromUser(variable Variable, options *BoilerplateOptions) (string, error) { - util.BRIGHT_GREEN.Printf("\n%s\n", variable.Description()) - if variable.Prompt != "" { - fmt.Printf(" %s\n", variable.Prompt) +func getVariableFromUser(variable Variable, options *BoilerplateOptions) (interface{}, error) { + util.BRIGHT_GREEN.Printf("\n%s\n", variable.FullName()) + if variable.Description != "" { + fmt.Printf(" %s\n", variable.Description) } if variable.Default != "" { fmt.Printf(" (default: %s)\n", variable.Default) @@ -97,16 +305,16 @@ func getVariableFromUser(variable Variable, options *BoilerplateOptions) (string if value == "" { // TODO: what if the user wanted an empty string instead of the default? - util.Logger.Printf("Using default value for variable '%s': %s", variable.Description(), variable.Default) + util.Logger.Printf("Using default value for variable '%s': %v", variable.FullName(), variable.Default) return variable.Default, nil - } else { - return value, nil } + + return parseYamlString(value) } // Parse a list of NAME=VALUE pairs into a map. -func ParseVariablesFromKeyValuePairs(varsList []string) (map[string]string, error) { - vars := map[string]string{} +func ParseVariablesFromKeyValuePairs(varsList []string) (map[string]interface{}, error) { + vars := map[string]interface{}{} for _, variable := range varsList { variableParts := strings.Split(variable, "=") @@ -120,15 +328,32 @@ func ParseVariablesFromKeyValuePairs(varsList []string) (map[string]string, err return vars, errors.WithStackTrace(VariableNameCannotBeEmpty(variable)) } - vars[key] = value + parsedValue, err := parseYamlString(value) + if err != nil { + return vars, err + } + + vars[key] = parsedValue } return vars, nil } +// Parse a YAML string into a Go type +func parseYamlString(str string) (interface{}, error) { + var parsedValue interface{} + + err := yaml.Unmarshal([]byte(str), &parsedValue) + if err != nil { + return nil, errors.WithStackTrace(err) + } + + return parsedValue, nil +} + // Parse a list of YAML files that define variables into a map. -func ParseVariablesFromVarFiles(varFileList []string) (map[string]string, error) { - vars := map[string]string{} +func ParseVariablesFromVarFiles(varFileList []string) (map[string]interface{}, error) { + vars := map[string]interface{}{} for _, varFile := range varFileList { varsInFile, err := ParseVariablesFromVarFile(varFile) @@ -142,17 +367,17 @@ func ParseVariablesFromVarFiles(varFileList []string) (map[string]string, error) } // Parse the NAME: VALUE pairs in the given YAML file into a map -func ParseVariablesFromVarFile(varFilePath string) (map[string]string, error) { +func ParseVariablesFromVarFile(varFilePath string) (map[string]interface{}, error) { bytes, err := ioutil.ReadFile(varFilePath) if err != nil { - return map[string]string{}, errors.WithStackTrace(err) + return map[string]interface{}{}, errors.WithStackTrace(err) } return parseVariablesFromVarFileContents(bytes) } // Parse the NAME: VALUE pairs in the given YAML file contents into a map -func parseVariablesFromVarFileContents(varFileContents []byte)(map[string]string, error) { - vars := map[string]string{} +func parseVariablesFromVarFileContents(varFileContents []byte)(map[string]interface{}, error) { + vars := map[string]interface{}{} err := yaml.Unmarshal(varFileContents, &vars) if err != nil { @@ -177,4 +402,30 @@ func (varSyntax InvalidVarSyntax) Error() string { type VariableNameCannotBeEmpty string func (varSyntax VariableNameCannotBeEmpty) Error() string { return fmt.Sprintf("Variable name cannot be empty. Expected NAME=VALUE but got %s", string(varSyntax)) +} + +type InvalidVariableValue struct { + Value interface{} + Variable Variable +} +func (err InvalidVariableValue) Error() string { + message := fmt.Sprintf("Value '%v' is not a valid value for variable '%s' with type '%s'.", err.Value, err.Variable.Name, err.Variable.Type.String()) + if err.Variable.Type == Enum { + message = fmt.Sprintf("%s. Value must be one of: %s.", message, err.Variable.Options) + } + return message +} + +type InvalidTypeForField struct { + FieldName string + VariableName string + ExpectedType string + ActualType string +} +func (err InvalidTypeForField) Error() string { + message := fmt.Sprintf("%s must have type %s but got %s", err.FieldName, err.ExpectedType, err.ActualType) + if err.VariableName != "" { + message = fmt.Sprintf("%s for variable %s", message, err.VariableName) + } + return message } \ No newline at end of file diff --git a/config/variables_test.go b/config/variables_test.go index edca7053..a87aa93d 100644 --- a/config/variables_test.go +++ b/config/variables_test.go @@ -23,7 +23,7 @@ func TestGetVariableFromVarsNoMatch(t *testing.T) { variable := Variable{Name: "foo"} options := &BoilerplateOptions{ - Vars: map[string]string{ + Vars: map[string]interface{}{ "key1": "value1", "key2": "value2", "key3": "value3", @@ -39,7 +39,7 @@ func TestGetVariableFromVarsMatch(t *testing.T) { variable := Variable{Name: "foo"} options := &BoilerplateOptions{ - Vars: map[string]string{ + Vars: map[string]interface{}{ "key1": "value1", "foo": "bar", "key3": "value3", @@ -58,7 +58,7 @@ func TestGetVariableFromVarsForDependencyNoMatch(t *testing.T) { variable := Variable{Name: "bar.foo"} options := &BoilerplateOptions{ - Vars: map[string]string{ + Vars: map[string]interface{}{ "key1": "value1", "foo": "bar", "key3": "value3", @@ -74,7 +74,7 @@ func TestGetVariableFromVarsForDependencyMatch(t *testing.T) { variable := Variable{Name: "bar.foo"} options := &BoilerplateOptions{ - Vars: map[string]string{ + Vars: map[string]interface{}{ "key1": "value1", "bar.foo": "bar", "key3": "value3", @@ -106,7 +106,7 @@ func TestGetVariableInVarsNonInteractive(t *testing.T) { variable := Variable{Name: "foo"} options := &BoilerplateOptions{ NonInteractive: true, - Vars: map[string]string{ + Vars: map[string]interface{}{ "key1": "value1", "foo": "bar", "key3": "value3", @@ -126,7 +126,7 @@ func TestGetVariableDefaultNonInteractive(t *testing.T) { variable := Variable{Name: "foo", Default: "bar"} options := &BoilerplateOptions{ NonInteractive: true, - Vars: map[string]string{ + Vars: map[string]interface{}{ "key1": "value1", "key2": "value2", "key3": "value3", @@ -147,7 +147,7 @@ func TestGetVariablesNoVariables(t *testing.T) { boilerplateConfig := &BoilerplateConfig{} actual, err := GetVariables(options, boilerplateConfig) - expected := map[string]string{} + expected := map[string]interface{}{} assert.Nil(t, err) assert.Equal(t, expected, actual) @@ -159,7 +159,7 @@ func TestGetVariablesNoMatchNonInteractive(t *testing.T) { options := &BoilerplateOptions{NonInteractive: true} boilerplateConfig := &BoilerplateConfig{ Variables: []Variable{ - Variable{Name: "foo"}, + {Name: "foo", Type: String}, }, } @@ -174,19 +174,19 @@ func TestGetVariablesMatchFromVars(t *testing.T) { options := &BoilerplateOptions{ NonInteractive: true, - Vars: map[string]string{ + Vars: map[string]interface{}{ "foo": "bar", }, } boilerplateConfig := &BoilerplateConfig{ Variables: []Variable{ - Variable{Name: "foo"}, + {Name: "foo", Type: String}, }, } actual, err := GetVariables(options, boilerplateConfig) - expected := map[string]string{ + expected := map[string]interface{}{ "foo": "bar", } @@ -199,7 +199,7 @@ func TestGetVariablesMatchFromVarsAndDefaults(t *testing.T) { options := &BoilerplateOptions{ NonInteractive: true, - Vars: map[string]string{ + Vars: map[string]interface{}{ "key1": "value1", "key2": "value2", }, @@ -207,14 +207,14 @@ func TestGetVariablesMatchFromVarsAndDefaults(t *testing.T) { boilerplateConfig := &BoilerplateConfig{ Variables: []Variable{ - Variable{Name: "key1"}, - Variable{Name: "key2"}, - Variable{Name: "key3", Default: "value3"}, + {Name: "key1", Type: String}, + {Name: "key2", Type: String}, + {Name: "key3", Type: String, Default: "value3"}, }, } actual, err := GetVariables(options, boilerplateConfig) - expected := map[string]string{ + expected := map[string]interface{}{ "key1": "value1", "key2": "value2", "key3": "value3", @@ -230,15 +230,15 @@ func TestParseVariablesFromKeyValuePairs(t *testing.T) { testCases := []struct { keyValuePairs []string expectedError error - expectedVars map[string]string + expectedVars map[string]interface{} }{ - {[]string{}, nil, map[string]string{}}, - {[]string{"key=value"}, nil, map[string]string{"key": "value"}}, - {[]string{"key="}, nil, map[string]string{"key": ""}}, - {[]string{"key1=value1", "key2=value2", "key3=value3"}, nil, map[string]string{"key1": "value1", "key2": "value2", "key3": "value3"}}, - {[]string{"invalidsyntax"}, InvalidVarSyntax("invalidsyntax"), map[string]string{}}, - {[]string{"="}, VariableNameCannotBeEmpty("="), map[string]string{}}, - {[]string{"=foo"}, VariableNameCannotBeEmpty("=foo"), map[string]string{}}, + {[]string{}, nil, map[string]interface{}{}}, + {[]string{"key=value"}, nil, map[string]interface{}{"key": "value"}}, + {[]string{"key="}, nil, map[string]interface{}{"key": nil}}, + {[]string{"key1=value1", "key2=value2", "key3=value3"}, nil, map[string]interface{}{"key1": "value1", "key2": "value2", "key3": "value3"}}, + {[]string{"invalidsyntax"}, InvalidVarSyntax("invalidsyntax"), map[string]interface{}{}}, + {[]string{"="}, VariableNameCannotBeEmpty("="), map[string]interface{}{}}, + {[]string{"=foo"}, VariableNameCannotBeEmpty("=foo"), map[string]interface{}{}}, } for _, testCase := range testCases { @@ -271,12 +271,12 @@ func TestParseVariablesFromVarFileContents(t *testing.T) { testCases := []struct { fileContents string expectYamlTypeError bool - expectedVars map[string]string + expectedVars map[string]interface{} }{ - {"", false, map[string]string{}}, - {YAML_FILE_ONE_VAR, false, map[string]string{"key": "value"}}, - {YAML_FILE_MULTIPLE_VARS, false, map[string]string{"key1": "value1", "key2": "value2", "key3": "value3"}}, - {"invalid yaml", true, map[string]string{}}, + {"", false, map[string]interface{}{}}, + {YAML_FILE_ONE_VAR, false, map[string]interface{}{"key": "value"}}, + {YAML_FILE_MULTIPLE_VARS, false, map[string]interface{}{"key1": "value1", "key2": "value2", "key3": "value3"}}, + {"invalid yaml", true, map[string]interface{}{}}, } for _, testCase := range testCases { diff --git a/errors/errors.go b/errors/errors.go index bd1bd8b9..5dbccdea 100644 --- a/errors/errors.go +++ b/errors/errors.go @@ -29,7 +29,7 @@ func WithStackTraceAndPrefix(err error, message string, args ... interface{}) er // Returns true if actual is the same type of error as expected. This method unwraps the given error objects (if they // are wrapped in objects with a stacktrace) and then does a simple equality check on them. func IsError(actual error, expected error) bool { - return goerrors.Is(actual, expected) + return goerrors.Is(Unwrap(actual), expected) } // If the given error is a wrapper that contains a stacktrace, unwrap it and return the original, underlying error. diff --git a/examples/dependencies-recursive/boilerplate.yml b/examples/dependencies-recursive/boilerplate.yml index 0f8aac43..c57e40bf 100644 --- a/examples/dependencies-recursive/boilerplate.yml +++ b/examples/dependencies-recursive/boilerplate.yml @@ -13,6 +13,7 @@ variables: - name: ShowLogo description: Should the webiste show the logo (true or false)? + type: bool default: true dependencies: diff --git a/examples/dependencies/boilerplate.yml b/examples/dependencies/boilerplate.yml index af20bb48..339ab052 100644 --- a/examples/dependencies/boilerplate.yml +++ b/examples/dependencies/boilerplate.yml @@ -13,6 +13,7 @@ variables: - name: ShowLogo description: Should the webiste show the logo (true or false)? + type: bool default: true dependencies: diff --git a/examples/website/index.html b/examples/website/index.html index ab2f5062..ddde4fa6 100644 --- a/examples/website/index.html +++ b/examples/website/index.html @@ -4,6 +4,6 @@

{{.WelcomeText}}

- {{if eq .ShowLogo "true"}}{{end}} + {{if .ShowLogo}}{{end}} \ No newline at end of file diff --git a/templates/template_processor.go b/templates/template_processor.go index 4d1fea3d..971e81e9 100644 --- a/templates/template_processor.go +++ b/templates/template_processor.go @@ -22,6 +22,8 @@ func ProcessTemplate(options *config.BoilerplateOptions) error { return err } + fmt.Printf("variables for template folder %s = %s\n", options.TemplateFolder, boilerplateConfig.Variables) + variables, err := config.GetVariables(options, boilerplateConfig) if err != nil { return err @@ -41,7 +43,7 @@ func ProcessTemplate(options *config.BoilerplateOptions) error { } // Execute the boilerplate templates in the given list of dependencies -func processDependencies(dependencies []config.Dependency, options *config.BoilerplateOptions, variables map[string]string) error { +func processDependencies(dependencies []config.Dependency, options *config.BoilerplateOptions, variables map[string]interface{}) error { for _, dependency := range dependencies { err := processDependency(dependency, options, variables) if err != nil { @@ -53,7 +55,7 @@ func processDependencies(dependencies []config.Dependency, options *config.Boile } // Execute the boilerplate template in the given dependency -func processDependency(dependency config.Dependency, options *config.BoilerplateOptions, variables map[string]string) error { +func processDependency(dependency config.Dependency, options *config.BoilerplateOptions, variables map[string]interface{}) error { shouldProcess, err := shouldProcessDependency(dependency, options) if err != nil { return err @@ -72,7 +74,7 @@ func processDependency(dependency config.Dependency, options *config.Boilerplate // Clone the given options for use when rendering the given dependency. The dependency will get the same options as // the original passed in, except for the template folder, output folder, and command-line vars. -func cloneOptionsForDependency(dependency config.Dependency, originalOptions *config.BoilerplateOptions, variables map[string]string) *config.BoilerplateOptions { +func cloneOptionsForDependency(dependency config.Dependency, originalOptions *config.BoilerplateOptions, variables map[string]interface{}) *config.BoilerplateOptions { templateFolder := pathRelativeToTemplate(originalOptions.TemplateFolder, dependency.TemplateFolder) outputFolder := pathRelativeToTemplate(originalOptions.OutputFolder, dependency.OutputFolder) @@ -82,14 +84,15 @@ func cloneOptionsForDependency(dependency config.Dependency, originalOptions *co NonInteractive: originalOptions.NonInteractive, Vars: cloneVariablesForDependency(dependency, variables), OnMissingKey: originalOptions.OnMissingKey, + OnMissingConfig: originalOptions.OnMissingConfig, } } // Clone the given variables for use when rendering the given dependency. The dependency will get the same variables // as the originals passed in, filtered to variable names that do not include a dependency or explicitly are for the // given dependency. If dependency.DontInheritVariables is set to true, an empty map is returned. -func cloneVariablesForDependency(dependency config.Dependency, originalVariables map[string]string) map[string]string { - newVariables := map[string]string{} +func cloneVariablesForDependency(dependency config.Dependency, originalVariables map[string]interface{}) map[string]interface{} { + newVariables := map[string]interface{}{} if dependency.DontInheritVariables { return newVariables @@ -119,7 +122,7 @@ func shouldProcessDependency(dependency config.Dependency, options *config.Boile // Copy all the files and folders in templateFolder to outputFolder, passing text files through the Go template engine // with the given set of variables as the data. -func processTemplateFolder(options *config.BoilerplateOptions, variables map[string]string) error { +func processTemplateFolder(options *config.BoilerplateOptions, variables map[string]interface{}) error { util.Logger.Printf("Processing templates in %s and outputting generated files to %s", options.TemplateFolder, options.OutputFolder) return filepath.Walk(options.TemplateFolder, func(path string, info os.FileInfo, err error) error { @@ -136,7 +139,7 @@ func processTemplateFolder(options *config.BoilerplateOptions, variables map[str // Copy the given path, which is in the folder templateFolder, to the outputFolder, passing it through the Go template // engine with the given set of variables as the data if it's a text file. -func processFile(path string, options *config.BoilerplateOptions, variables map[string]string) error { +func processFile(path string, options *config.BoilerplateOptions, variables map[string]interface{}) error { isText, err := util.IsTextFile(path) if err != nil { return err @@ -150,7 +153,7 @@ func processFile(path string, options *config.BoilerplateOptions, variables map[ } // Create the given directory, which is in templateFolder, in the given outputFolder -func createOutputDir(dir string, options *config.BoilerplateOptions, variables map[string]string) error { +func createOutputDir(dir string, options *config.BoilerplateOptions, variables map[string]interface{}) error { destination, err := outPath(dir, options, variables) if err != nil { return err @@ -163,7 +166,7 @@ func createOutputDir(dir string, options *config.BoilerplateOptions, variables m // Compute the path where the given file, which is in templateFolder, should be copied in outputFolder. If the file // path contains boilerplate syntax, use the given options and variables to render it to determine the final output // path. -func outPath(file string, options *config.BoilerplateOptions, variables map[string]string) (string, error) { +func outPath(file string, options *config.BoilerplateOptions, variables map[string]interface{}) (string, error) { templateFolderAbsPath, err := filepath.Abs(options.TemplateFolder) if err != nil { return "", errors.WithStackTrace(err) @@ -188,7 +191,7 @@ func outPath(file string, options *config.BoilerplateOptions, variables map[stri } // Copy the given file, which is in options.TemplateFolder, to options.OutputFolder -func copyFile(file string, options *config.BoilerplateOptions, variables map[string]string) error { +func copyFile(file string, options *config.BoilerplateOptions, variables map[string]interface{}) error { destination, err := outPath(file, options, variables) if err != nil { return err @@ -200,7 +203,7 @@ func copyFile(file string, options *config.BoilerplateOptions, variables map[str // Run the template at templatePath, which is in templateFolder, through the Go template engine with the given // variables as data and write the result to outputFolder -func processTemplate(templatePath string, options *config.BoilerplateOptions, variables map[string]string) error { +func processTemplate(templatePath string, options *config.BoilerplateOptions, variables map[string]interface{}) error { destination, err := outPath(templatePath, options, variables) if err != nil { return err @@ -227,8 +230,9 @@ func shouldSkipPath(path string, options *config.BoilerplateOptions) bool { // Render the template at templatePath, with contents templateContents, using the Go template engine, passing in the // given variables as data. -func renderTemplate(templatePath string, templateContents string, variables map[string]string, missingKeyAction config.MissingKeyAction) (string, error) { - template := template.New(templatePath).Funcs(CreateTemplateHelpers(templatePath)).Option("missingkey=" + string(missingKeyAction)) +func renderTemplate(templatePath string, templateContents string, variables map[string]interface{}, missingKeyAction config.MissingKeyAction) (string, error) { + option := fmt.Sprintf("missingkey=%s", string(missingKeyAction)) + template := template.New(templatePath).Funcs(CreateTemplateHelpers(templatePath)).Option(option) parsedTemplate, err := template.Parse(templateContents) if err != nil { diff --git a/templates/template_processor_test.go b/templates/template_processor_test.go index 8fe2ee2a..d83db3e0 100644 --- a/templates/template_processor_test.go +++ b/templates/template_processor_test.go @@ -17,15 +17,15 @@ func TestOutPath(t *testing.T) { file string templateFolder string outputFolder string - variables map[string]string + variables map[string]interface{} expected string }{ - {"template-folder/foo.txt", "template-folder", "output-folder", map[string]string{}, "output-folder/foo.txt"}, - {"foo/bar/template-folder/foo.txt", "foo/bar/template-folder", "output-folder", map[string]string{}, "output-folder/foo.txt"}, - {"template-folder/foo.txt", pwd + "/template-folder", "output-folder", map[string]string{}, "output-folder/foo.txt"}, - {"template-folder/foo/bar/baz.txt", pwd + "/template-folder", "output-folder", map[string]string{}, "output-folder/foo/bar/baz.txt"}, - {"template-folder/{{.Foo}}.txt", pwd + "/template-folder", "output-folder", map[string]string{"Foo": "foo"}, "output-folder/foo.txt"}, - {"template-folder/{{.Foo | dasherize}}.txt", pwd + "/template-folder", "output-folder", map[string]string{"Foo": "Foo Bar Baz"}, "output-folder/foo-bar-baz.txt"}, + {"template-folder/foo.txt", "template-folder", "output-folder", map[string]interface{}{}, "output-folder/foo.txt"}, + {"foo/bar/template-folder/foo.txt", "foo/bar/template-folder", "output-folder", map[string]interface{}{}, "output-folder/foo.txt"}, + {"template-folder/foo.txt", pwd + "/template-folder", "output-folder", map[string]interface{}{}, "output-folder/foo.txt"}, + {"template-folder/foo/bar/baz.txt", pwd + "/template-folder", "output-folder", map[string]interface{}{}, "output-folder/foo/bar/baz.txt"}, + {"template-folder/{{.Foo}}.txt", pwd + "/template-folder", "output-folder", map[string]interface{}{"Foo": "foo"}, "output-folder/foo.txt"}, + {"template-folder/{{.Foo | dasherize}}.txt", pwd + "/template-folder", "output-folder", map[string]interface{}{"Foo": "Foo Bar Baz"}, "output-folder/foo-bar-baz.txt"}, } for _, testCase := range testCases { @@ -78,40 +78,41 @@ func TestRenderTemplate(t *testing.T) { testCases := []struct { templateContents string - variables map[string]string + variables map[string]interface{} missingKeyAction config.MissingKeyAction expectedErrorText string expectedOutput string }{ - {"", map[string]string{}, config.ExitWithError, "", ""}, - {"plain text template", map[string]string{}, config.ExitWithError, "", "plain text template"}, - {"variable lookup: {{.Foo}}", map[string]string{"Foo": "bar"}, config.ExitWithError, "", "variable lookup: bar"}, - {"missing variable lookup, ExitWithError: {{.Foo}}", map[string]string{}, config.ExitWithError, "map has no entry for key \"Foo\"", ""}, - {"missing variable lookup, Invalid: {{.Foo}}", map[string]string{}, config.Invalid, "", "missing variable lookup, Invalid: "}, - {"missing variable lookup, ZeroValue: {{.Foo}}", map[string]string{}, config.ZeroValue, "", "missing variable lookup, ZeroValue: "}, - {EMBED_WHOLE_FILE_TEMPLATE, map[string]string{}, config.ExitWithError, "", EMBED_WHOLE_FILE_TEMPLATE_OUTPUT}, - {EMBED_SNIPPET_TEMPLATE, map[string]string{}, config.ExitWithError, "", EMBED_SNIPPET_TEMPLATE_OUTPUT}, - {"Invalid template syntax: {{.Foo", map[string]string{}, config.ExitWithError, "unclosed action", ""}, - {"Uppercase test: {{ .Foo | upcase }}", map[string]string{"Foo": "some text"}, config.ExitWithError, "", "Uppercase test: SOME TEXT"}, - {"Lowercase test: {{ .Foo | downcase }}", map[string]string{"Foo": "SOME TEXT"}, config.ExitWithError, "", "Lowercase test: some text"}, - {"Capitalize test: {{ .Foo | capitalize }}", map[string]string{"Foo": "some text"}, config.ExitWithError, "", "Capitalize test: Some Text"}, - {"Replace test: {{ .Foo | replace \"foo\" \"bar\" }}", map[string]string{"Foo": "hello foo, how are foo"}, config.ExitWithError, "", "Replace test: hello bar, how are foo"}, - {"Replace all test: {{ .Foo | replaceAll \"foo\" \"bar\" }}", map[string]string{"Foo": "hello foo, how are foo"}, config.ExitWithError, "", "Replace all test: hello bar, how are bar"}, - {"Trim test: {{ .Foo | trim }}", map[string]string{"Foo": " some text \t"}, config.ExitWithError, "", "Trim test: some text"}, - {"Round test: {{ .Foo | round }}", map[string]string{"Foo": "0.45"}, config.ExitWithError, "", "Round test: 0"}, - {"Ceil test: {{ .Foo | ceil }}", map[string]string{"Foo": "0.45"}, config.ExitWithError, "", "Ceil test: 1"}, - {"Floor test: {{ .Foo | floor }}", map[string]string{"Foo": "0.45"}, config.ExitWithError, "", "Floor test: 0"}, - {"Dasherize test: {{ .Foo | dasherize }}", map[string]string{"Foo": "foo BAR baz!"}, config.ExitWithError, "", "Dasherize test: foo-bar-baz"}, - {"Snake case test: {{ .Foo | snakeCase }}", map[string]string{"Foo": "foo BAR baz!"}, config.ExitWithError, "", "Snake case test: foo_bar_baz"}, - {"Camel case test: {{ .Foo | camelCase }}", map[string]string{"Foo": "foo BAR baz!"}, config.ExitWithError, "", "Camel case test: FooBARBaz"}, - {"Camel case lower test: {{ .Foo | camelCaseLower }}", map[string]string{"Foo": "foo BAR baz!"}, config.ExitWithError, "", "Camel case lower test: fooBARBaz"}, - {"Plus test: {{ plus .Foo .Bar }}", map[string]string{"Foo": "5", "Bar": "3"}, config.ExitWithError, "", "Plus test: 8"}, - {"Minus test: {{ minus .Foo .Bar }}", map[string]string{"Foo": "5", "Bar": "3"}, config.ExitWithError, "", "Minus test: 2"}, - {"Times test: {{ times .Foo .Bar }}", map[string]string{"Foo": "5", "Bar": "3"}, config.ExitWithError, "", "Times test: 15"}, - {"Divide test: {{ divide .Foo .Bar | printf \"%1.5f\" }}", map[string]string{"Foo": "5", "Bar": "3"}, config.ExitWithError, "", "Divide test: 1.66667"}, - {"Mod test: {{ mod .Foo .Bar }}", map[string]string{"Foo": "5", "Bar": "3"}, config.ExitWithError, "", "Mod test: 2"}, - {"Slice test: {{ slice 0 5 1 }}", map[string]string{}, config.ExitWithError, "", "Slice test: [0 1 2 3 4]"}, - {"Filter chain test: {{ .Foo | downcase | replaceAll \" \" \"\" }}", map[string]string{"Foo": "foo BAR baz!"}, config.ExitWithError, "", "Filter chain test: foobarbaz!"}, + {"", map[string]interface{}{}, config.ExitWithError, "", ""}, + {"plain text template", map[string]interface{}{}, config.ExitWithError, "", "plain text template"}, + {"variable lookup: {{.Foo}}", map[string]interface{}{"Foo": "bar"}, config.ExitWithError, "", "variable lookup: bar"}, + {"missing variable lookup, ExitWithError: {{.Foo}}", map[string]interface{}{}, config.ExitWithError, "map has no entry for key \"Foo\"", ""}, + {"missing variable lookup, Invalid: {{.Foo}}", map[string]interface{}{}, config.Invalid, "", "missing variable lookup, Invalid: "}, + // Note: config.ZeroValue does not work correctly with Go templating when you pass in a map[string]interface{}. For some reason, it always prints . + {"missing variable lookup, ZeroValue: {{.Foo}}", map[string]interface{}{}, config.ZeroValue, "", "missing variable lookup, ZeroValue: "}, + {EMBED_WHOLE_FILE_TEMPLATE, map[string]interface{}{}, config.ExitWithError, "", EMBED_WHOLE_FILE_TEMPLATE_OUTPUT}, + {EMBED_SNIPPET_TEMPLATE, map[string]interface{}{}, config.ExitWithError, "", EMBED_SNIPPET_TEMPLATE_OUTPUT}, + {"Invalid template syntax: {{.Foo", map[string]interface{}{}, config.ExitWithError, "unclosed action", ""}, + {"Uppercase test: {{ .Foo | upcase }}", map[string]interface{}{"Foo": "some text"}, config.ExitWithError, "", "Uppercase test: SOME TEXT"}, + {"Lowercase test: {{ .Foo | downcase }}", map[string]interface{}{"Foo": "SOME TEXT"}, config.ExitWithError, "", "Lowercase test: some text"}, + {"Capitalize test: {{ .Foo | capitalize }}", map[string]interface{}{"Foo": "some text"}, config.ExitWithError, "", "Capitalize test: Some Text"}, + {"Replace test: {{ .Foo | replace \"foo\" \"bar\" }}", map[string]interface{}{"Foo": "hello foo, how are foo"}, config.ExitWithError, "", "Replace test: hello bar, how are foo"}, + {"Replace all test: {{ .Foo | replaceAll \"foo\" \"bar\" }}", map[string]interface{}{"Foo": "hello foo, how are foo"}, config.ExitWithError, "", "Replace all test: hello bar, how are bar"}, + {"Trim test: {{ .Foo | trim }}", map[string]interface{}{"Foo": " some text \t"}, config.ExitWithError, "", "Trim test: some text"}, + {"Round test: {{ .Foo | round }}", map[string]interface{}{"Foo": "0.45"}, config.ExitWithError, "", "Round test: 0"}, + {"Ceil test: {{ .Foo | ceil }}", map[string]interface{}{"Foo": "0.45"}, config.ExitWithError, "", "Ceil test: 1"}, + {"Floor test: {{ .Foo | floor }}", map[string]interface{}{"Foo": "0.45"}, config.ExitWithError, "", "Floor test: 0"}, + {"Dasherize test: {{ .Foo | dasherize }}", map[string]interface{}{"Foo": "foo BAR baz!"}, config.ExitWithError, "", "Dasherize test: foo-bar-baz"}, + {"Snake case test: {{ .Foo | snakeCase }}", map[string]interface{}{"Foo": "foo BAR baz!"}, config.ExitWithError, "", "Snake case test: foo_bar_baz"}, + {"Camel case test: {{ .Foo | camelCase }}", map[string]interface{}{"Foo": "foo BAR baz!"}, config.ExitWithError, "", "Camel case test: FooBARBaz"}, + {"Camel case lower test: {{ .Foo | camelCaseLower }}", map[string]interface{}{"Foo": "foo BAR baz!"}, config.ExitWithError, "", "Camel case lower test: fooBARBaz"}, + {"Plus test: {{ plus .Foo .Bar }}", map[string]interface{}{"Foo": "5", "Bar": "3"}, config.ExitWithError, "", "Plus test: 8"}, + {"Minus test: {{ minus .Foo .Bar }}", map[string]interface{}{"Foo": "5", "Bar": "3"}, config.ExitWithError, "", "Minus test: 2"}, + {"Times test: {{ times .Foo .Bar }}", map[string]interface{}{"Foo": "5", "Bar": "3"}, config.ExitWithError, "", "Times test: 15"}, + {"Divide test: {{ divide .Foo .Bar | printf \"%1.5f\" }}", map[string]interface{}{"Foo": "5", "Bar": "3"}, config.ExitWithError, "", "Divide test: 1.66667"}, + {"Mod test: {{ mod .Foo .Bar }}", map[string]interface{}{"Foo": "5", "Bar": "3"}, config.ExitWithError, "", "Mod test: 2"}, + {"Slice test: {{ slice 0 5 1 }}", map[string]interface{}{}, config.ExitWithError, "", "Slice test: [0 1 2 3 4]"}, + {"Filter chain test: {{ .Foo | downcase | replaceAll \" \" \"\" }}", map[string]interface{}{"Foo": "foo BAR baz!"}, config.ExitWithError, "", "Filter chain test: foobarbaz!"}, } for _, testCase := range testCases { @@ -132,20 +133,20 @@ func TestCloneOptionsForDependency(t *testing.T) { testCases := []struct { dependency config.Dependency options config.BoilerplateOptions - variables map[string]string + variables map[string]interface{} expectedOptions config.BoilerplateOptions }{ { config.Dependency{Name: "dep1", TemplateFolder: "../dep1", OutputFolder: "../out1"}, - config.BoilerplateOptions{TemplateFolder: "/template/path/", OutputFolder: "/output/path/", NonInteractive: true, Vars: map[string]string{}, OnMissingKey: config.ExitWithError}, - map[string]string{}, - config.BoilerplateOptions{TemplateFolder: "/template/dep1", OutputFolder: "/output/out1", NonInteractive: true, Vars: map[string]string{}, OnMissingKey: config.ExitWithError}, + config.BoilerplateOptions{TemplateFolder: "/template/path/", OutputFolder: "/output/path/", NonInteractive: true, Vars: map[string]interface{}{}, OnMissingKey: config.ExitWithError}, + map[string]interface{}{}, + config.BoilerplateOptions{TemplateFolder: "/template/dep1", OutputFolder: "/output/out1", NonInteractive: true, Vars: map[string]interface{}{}, OnMissingKey: config.ExitWithError}, }, { config.Dependency{Name: "dep1", TemplateFolder: "../dep1", OutputFolder: "../out1"}, - config.BoilerplateOptions{TemplateFolder: "/template/path/", OutputFolder: "/output/path/", NonInteractive: false, Vars: map[string]string{"foo": "bar"}, OnMissingKey: config.Invalid}, - map[string]string{"baz": "blah"}, - config.BoilerplateOptions{TemplateFolder: "/template/dep1", OutputFolder: "/output/out1", NonInteractive: false, Vars: map[string]string{"baz": "blah"}, OnMissingKey: config.Invalid}, + config.BoilerplateOptions{TemplateFolder: "/template/path/", OutputFolder: "/output/path/", NonInteractive: false, Vars: map[string]interface{}{"foo": "bar"}, OnMissingKey: config.Invalid}, + map[string]interface{}{"baz": "blah"}, + config.BoilerplateOptions{TemplateFolder: "/template/dep1", OutputFolder: "/output/out1", NonInteractive: false, Vars: map[string]interface{}{"baz": "blah"}, OnMissingKey: config.Invalid}, }, } @@ -160,33 +161,33 @@ func TestCloneVariablesForDependency(t *testing.T) { testCases := []struct { dependency config.Dependency - variables map[string]string - expectedVariables map[string]string + variables map[string]interface{} + expectedVariables map[string]interface{} }{ { config.Dependency{Name: "dep1", TemplateFolder: "../dep1", OutputFolder: "../out1"}, - map[string]string{}, - map[string]string{}, + map[string]interface{}{}, + map[string]interface{}{}, }, { config.Dependency{Name: "dep1", TemplateFolder: "../dep1", OutputFolder: "../out1"}, - map[string]string{"foo": "bar", "baz": "blah"}, - map[string]string{"foo": "bar", "baz": "blah"}, + map[string]interface{}{"foo": "bar", "baz": "blah"}, + map[string]interface{}{"foo": "bar", "baz": "blah"}, }, { config.Dependency{Name: "dep1", TemplateFolder: "../dep1", OutputFolder: "../out1"}, - map[string]string{"foo": "bar", "baz": "blah", "dep1.abc": "should-modify-name", "dep2.def": "should-copy-unmodified"}, - map[string]string{"foo": "bar", "baz": "blah", "abc": "should-modify-name", "dep2.def": "should-copy-unmodified"}, + map[string]interface{}{"foo": "bar", "baz": "blah", "dep1.abc": "should-modify-name", "dep2.def": "should-copy-unmodified"}, + map[string]interface{}{"foo": "bar", "baz": "blah", "abc": "should-modify-name", "dep2.def": "should-copy-unmodified"}, }, { config.Dependency{Name: "dep1", TemplateFolder: "../dep1", OutputFolder: "../out1"}, - map[string]string{"foo": "bar", "baz": "blah", "dep1.abc": "should-modify-name", "dep2.def": "should-copy-unmodified", "abc": "should-be-overwritten-by-dep1.abc"}, - map[string]string{"foo": "bar", "baz": "blah", "abc": "should-modify-name", "dep2.def": "should-copy-unmodified"}, + map[string]interface{}{"foo": "bar", "baz": "blah", "dep1.abc": "should-modify-name", "dep2.def": "should-copy-unmodified", "abc": "should-be-overwritten-by-dep1.abc"}, + map[string]interface{}{"foo": "bar", "baz": "blah", "abc": "should-modify-name", "dep2.def": "should-copy-unmodified"}, }, { config.Dependency{Name: "dep1", TemplateFolder: "../dep1", OutputFolder: "../out1", DontInheritVariables: true}, - map[string]string{"foo": "bar", "baz": "blah", "dep1.abc": "should-modify-name", "dep2.def": "should-copy-unmodified"}, - map[string]string{}, + map[string]interface{}{"foo": "bar", "baz": "blah", "dep1.abc": "should-modify-name", "dep2.def": "should-copy-unmodified"}, + map[string]interface{}{}, }, } diff --git a/test-fixtures/config-test/full-config/boilerplate.yml b/test-fixtures/config-test/full-config/boilerplate.yml index a2508a30..8fbcb760 100644 --- a/test-fixtures/config-test/full-config/boilerplate.yml +++ b/test-fixtures/config-test/full-config/boilerplate.yml @@ -2,10 +2,10 @@ variables: - name: foo - name: bar - description: prompt + description: example description - name: baz - description: prompt + description: example description default: default dependencies: @@ -19,10 +19,10 @@ dependencies: dont-inherit-variables: true variables: - name: baz - description: prompt + description: example description default: other-default - name: abc - description: prompt + description: example description default: default diff --git a/test-fixtures/examples-expected-output/dependencies-recursive/dependencies/docs/README.md b/test-fixtures/examples-expected-output/dependencies-recursive/dependencies/docs/README.md index b92770c4..d441b336 100644 --- a/test-fixtures/examples-expected-output/dependencies-recursive/dependencies/docs/README.md +++ b/test-fixtures/examples-expected-output/dependencies-recursive/dependencies/docs/README.md @@ -22,7 +22,7 @@ Here is how to use the `snippet` helper to embed files or parts of files from so

{{.WelcomeText}}

- {{if eq .ShowLogo "true"}}{{end}} + {{if .ShowLogo}}{{end}} ``` diff --git a/test-fixtures/examples-expected-output/dependencies/docs/README.md b/test-fixtures/examples-expected-output/dependencies/docs/README.md index b92770c4..d441b336 100644 --- a/test-fixtures/examples-expected-output/dependencies/docs/README.md +++ b/test-fixtures/examples-expected-output/dependencies/docs/README.md @@ -22,7 +22,7 @@ Here is how to use the `snippet` helper to embed files or parts of files from so

{{.WelcomeText}}

- {{if eq .ShowLogo "true"}}{{end}} + {{if .ShowLogo}}{{end}} ``` diff --git a/test-fixtures/examples-expected-output/docs/README.md b/test-fixtures/examples-expected-output/docs/README.md index b92770c4..d441b336 100644 --- a/test-fixtures/examples-expected-output/docs/README.md +++ b/test-fixtures/examples-expected-output/docs/README.md @@ -22,7 +22,7 @@ Here is how to use the `snippet` helper to embed files or parts of files from so

{{.WelcomeText}}

- {{if eq .ShowLogo "true"}}{{end}} + {{if .ShowLogo}}{{end}} ``` diff --git a/util/collections.go b/util/collections.go index ad7d2c47..5bb10997 100644 --- a/util/collections.go +++ b/util/collections.go @@ -1,8 +1,8 @@ package util -// Merge all the maps into one. Sadly, Go has no generics, so this is only defined for string maps. -func MergeMaps(maps ... map[string]string) map[string]string { - out := map[string]string{} +// Merge all the maps into one. Sadly, Go has no generics, so this is only defined for string to interface maps. +func MergeMaps(maps ... map[string]interface{}) map[string]interface{} { + out := map[string]interface{}{} for _, currMap := range maps { for key, value := range currMap { From 7f7f611c49532bece666edb51ec2be55ca30c9c4 Mon Sep 17 00:00:00 2001 From: Yevgeniy Brikman Date: Sun, 18 Sep 2016 23:57:05 +0100 Subject: [PATCH 3/7] Refactor config and variable logic into multiple, smaller files --- cli/boilerplate_cli.go | 63 +----- config/config.go | 195 +----------------- config/config_test.go | 24 +-- config/dependencies.go | 75 +++++++ config/dependencies_test.go | 28 +++ config/options.go | 141 +++++++++++++ config/types.go | 12 +- config/variables.go | 276 +------------------------- config/variables_from_yaml.go | 308 +++++++++++++++++++++++++++++ config/variables_from_yaml_test.go | 79 ++++++++ config/variables_test.go | 68 ------- util/collections.go | 29 +++ 12 files changed, 684 insertions(+), 614 deletions(-) create mode 100644 config/dependencies.go create mode 100644 config/dependencies_test.go create mode 100644 config/options.go create mode 100644 config/variables_from_yaml.go create mode 100644 config/variables_from_yaml_test.go diff --git a/cli/boilerplate_cli.go b/cli/boilerplate_cli.go index 32e63a5b..349f966b 100644 --- a/cli/boilerplate_cli.go +++ b/cli/boilerplate_cli.go @@ -5,7 +5,6 @@ import ( "fmt" "github.com/gruntwork-io/boilerplate/config" "github.com/gruntwork-io/boilerplate/templates" - "github.com/gruntwork-io/boilerplate/util" ) // Customize the --help text for the app so we don't show extraneous info @@ -96,70 +95,10 @@ func runApp(cliContext *cli.Context) error { return nil } - options, err := parseOptions(cliContext) + options, err := config.ParseOptions(cliContext) if err != nil { return err } return templates.ProcessTemplate(options) } - -// Parse the command line options provided by the user -func parseOptions(cliContext *cli.Context) (*config.BoilerplateOptions, error) { - vars, err := parseVars(cliContext.StringSlice(config.OPT_VAR), cliContext.StringSlice(config.OPT_VAR_FILE)) - if err != nil { - return nil, err - } - - missingKeyAction := config.DEFAULT_MISSING_KEY_ACTION - missingKeyActionValue := cliContext.String(config.OPT_MISSING_KEY_ACTION) - if missingKeyActionValue != "" { - missingKeyAction, err = config.ParseMissingKeyAction(missingKeyActionValue) - if err != nil { - return nil, err - } - } - - missingConfigAction := config.DEFAULT_MISSING_CONFIG_ACTION - missingConfigActionValue := cliContext.String(config.OPT_MISSING_CONFIG_ACTION) - if missingConfigActionValue != "" { - missingConfigAction, err = config.ParseMissingConfigAction(missingConfigActionValue) - if err != nil { - return nil, err - } - } - - options := &config.BoilerplateOptions{ - TemplateFolder: cliContext.String(config.OPT_TEMPLATE_FOLDER), - OutputFolder: cliContext.String(config.OPT_OUTPUT_FOLDER), - NonInteractive: cliContext.Bool(config.OPT_NON_INTERACTIVE), - OnMissingKey: missingKeyAction, - OnMissingConfig: missingConfigAction, - Vars: vars, - } - - if err := options.Validate(); err != nil { - return nil, err - } - - return options, nil -} - -// Parse variables passed in via command line flags, either as a list of NAME=VALUE variable pairs in varsList, or a -// list of paths to YAML files that define NAME: VALUE pairs. Return a map of the NAME: VALUE pairs. -func parseVars(varsList []string, varFileList[]string) (map[string]interface{}, error) { - variables := map[string]interface{}{} - - varsFromVarsList, err := config.ParseVariablesFromKeyValuePairs(varsList) - if err != nil { - return variables, err - } - - varsFromVarFiles, err := config.ParseVariablesFromVarFiles(varFileList) - if err != nil { - return variables, err - } - - return util.MergeMaps(varsFromVarsList, varsFromVarFiles), nil -} - diff --git a/config/config.go b/config/config.go index 750b55ac..6bf6a9e9 100644 --- a/config/config.go +++ b/config/config.go @@ -7,7 +7,6 @@ import ( "github.com/gruntwork-io/boilerplate/util" "fmt" "github.com/gruntwork-io/boilerplate/errors" - "strings" ) const BOILERPLATE_CONFIG_FILE = "boilerplate.yml" @@ -20,110 +19,12 @@ const OPT_VAR_FILE = "var-file" const OPT_MISSING_KEY_ACTION = "missing-key-action" const OPT_MISSING_CONFIG_ACTION = "missing-config-action" -// The command-line options for the boilerplate app -type BoilerplateOptions struct { - TemplateFolder string - OutputFolder string - NonInteractive bool - Vars map[string]interface{} - OnMissingKey MissingKeyAction - OnMissingConfig MissingConfigAction -} - -// This type is an enum that represents what we can do when a template looks up a missing key. This typically happens -// when there is a typo in the variable name in a template. -type MissingKeyAction string -var ( - Invalid = MissingKeyAction("invalid") // print for any missing key - ZeroValue = MissingKeyAction("zero") // print the zero value of the missing key - ExitWithError = MissingKeyAction("error") // exit with an error when there is a missing key -) - -var ALL_MISSING_KEY_ACTIONS = []MissingKeyAction{Invalid, ZeroValue, ExitWithError} -var DEFAULT_MISSING_KEY_ACTION = ExitWithError - -// Convert the given string to a MissingKeyAction enum, or return an error if this is not a valid value for the -// MissingKeyAction enum -func ParseMissingKeyAction(str string) (MissingKeyAction, error) { - for _, missingKeyAction := range ALL_MISSING_KEY_ACTIONS { - if string(missingKeyAction) == str { - return missingKeyAction, nil - } - } - return MissingKeyAction(""), errors.WithStackTrace(InvalidMissingKeyAction(str)) -} - -// This type is an enum that represents what to do when the template folder passed to boilerplate does not contain a -// boilerplate.yml file. -type MissingConfigAction string -var ( - Exit = MissingConfigAction("exit") - Ignore = MissingConfigAction("ignore") -) -var ALL_MISSING_CONFIG_ACTIONS = []MissingConfigAction{Exit, Ignore} -var DEFAULT_MISSING_CONFIG_ACTION = Exit - -// Convert the given string to a MissingConfigAction enum, or return an error if this is not a valid value for the -// MissingConfigAction enum -func ParseMissingConfigAction(str string) (MissingConfigAction, error) { - for _, missingConfigAction := range ALL_MISSING_CONFIG_ACTIONS { - if string(missingConfigAction) == str { - return missingConfigAction, nil - } - } - return MissingConfigAction(""), errors.WithStackTrace(InvalidMissingConfigAction(str)) -} - -// Validate that the options have reasonable values and return an error if they don't -func (options *BoilerplateOptions) Validate() error { - if options.TemplateFolder == "" { - return errors.WithStackTrace(TemplateFolderOptionCannotBeEmpty) - } - - if !util.PathExists(options.TemplateFolder) { - return errors.WithStackTrace(TemplateFolderDoesNotExist(options.TemplateFolder)) - } - - if options.OutputFolder == "" { - return errors.WithStackTrace(OutputFolderOptionCannotBeEmpty) - } - - return nil -} - // The contents of a boilerplate.yml config file type BoilerplateConfig struct { Variables []Variable Dependencies []Dependency } -// Given a unique variable name, return a tuple that contains the dependency name (if any) and the variable name. -// Variable and dependency names are split by a dot, so for "foo.bar", this will return ("foo", "bar"). For just "foo", -// it will return ("", "foo"). -func SplitIntoDependencyNameAndVariableName(uniqueVariableName string) (string, string) { - parts := strings.SplitAfterN(uniqueVariableName, ".", 2) - if len(parts) == 2 { - // The split method leaves the character you split on at the end of the string, so we have to trim it - return strings.TrimSuffix(parts[0], "."), parts[1] - } else { - return "", parts[0] - } -} - -// A single boilerplate template that this boilerplate.yml depends on being executed first -type Dependency struct { - Name string - TemplateFolder string `yaml:"template-folder"` - OutputFolder string `yaml:"output-folder"` - DontInheritVariables bool `yaml:"dont-inherit-variables"` - Variables []Variable -} - -// Return the default path for a boilerplate.yml config file in the given folder -func BoilerplateConfigPath(templateFolder string) string { - return path.Join(templateFolder, BOILERPLATE_CONFIG_FILE) -} - // Load the boilerplate.yml config contents for the folder specified in the given options func LoadBoilerplateConfig(options *BoilerplateOptions) (*BoilerplateConfig, error) { configPath := BoilerplateConfigPath(options.TemplateFolder) @@ -132,7 +33,7 @@ func LoadBoilerplateConfig(options *BoilerplateOptions) (*BoilerplateConfig, err util.Logger.Printf("Loading boilerplate config from %s", configPath) bytes, err := ioutil.ReadFile(configPath) if err != nil { - return nil, err + return nil, errors.WithStackTrace(err) } return ParseBoilerplateConfig(bytes) @@ -140,7 +41,7 @@ func LoadBoilerplateConfig(options *BoilerplateOptions) (*BoilerplateConfig, err util.Logger.Printf("Warning: boilerplate config file not found at %s. The %s flag is set, so ignoring. Note that no variables will be available while generating.", configPath, OPT_MISSING_CONFIG_ACTION) return &BoilerplateConfig{}, nil } else { - return nil, BoilerplateConfigNotFound(configPath) + return nil, errors.WithStackTrace(BoilerplateConfigNotFound(configPath)) } } @@ -149,7 +50,7 @@ func ParseBoilerplateConfig(configContents []byte) (*BoilerplateConfig, error) { boilerplateConfig := &BoilerplateConfig{} if err := yaml.Unmarshal(configContents, boilerplateConfig); err != nil { - return nil, err + return nil, errors.WithStackTrace(err) } if err := boilerplateConfig.validate(); err != nil { @@ -159,6 +60,11 @@ func ParseBoilerplateConfig(configContents []byte) (*BoilerplateConfig, error) { return boilerplateConfig, nil } +// Return the default path for a boilerplate.yml config file in the given folder +func BoilerplateConfigPath(templateFolder string) string { + return path.Join(templateFolder, BOILERPLATE_CONFIG_FILE) +} + // Validate that the config file has reasonable contents and return an error if there is a problem func (boilerplateConfig BoilerplateConfig) validate() error { if err := validateDependencies(boilerplateConfig.Dependencies); err != nil { @@ -168,94 +74,9 @@ func (boilerplateConfig BoilerplateConfig) validate() error { return nil } -// Validate that the list of dependencies has reasonable contents and return an error if there is a problem -func validateDependencies(dependencies []Dependency) error { - dependencyNames := []string{} - for i, dependency := range dependencies { - if dependency.Name == "" { - return errors.WithStackTrace(MissingNameForDependency(i)) - } - if util.ListContains(dependency.Name, dependencyNames) { - return errors.WithStackTrace(DuplicateDependencyName(dependency.Name)) - } - dependencyNames = append(dependencyNames, dependency.Name) - - if dependency.TemplateFolder == "" { - return errors.WithStackTrace(MissingTemplateFolderForDependency(dependency.Name)) - } - if dependency.OutputFolder == "" { - return errors.WithStackTrace(MissingOutputFolderForDependency(dependency.Name)) - } - } - - return nil -} - // Custom error types -var TemplateFolderOptionCannotBeEmpty = fmt.Errorf("The --%s option cannot be empty", OPT_TEMPLATE_FOLDER) - -var OutputFolderOptionCannotBeEmpty = fmt.Errorf("The --%s option cannot be empty", OPT_OUTPUT_FOLDER) - -type RequiredFieldMissing string -func (err RequiredFieldMissing) Error() string { - return fmt.Sprintf("Variable is missing required field %s", string(err)) -} - -type VariableMissingOptions string -func (err VariableMissingOptions) Error() string { - return fmt.Sprintf("Variable %s has type %s but does not specify any options. You must specify at least one option.", string(err), Enum) -} - -type OptionsCanOnlyBeUsedWithEnum struct { - VariableName string - VariableType BoilerplateType -} -func (err OptionsCanOnlyBeUsedWithEnum) Error() string { - return fmt.Sprintf("Variable %s has type %s and tries to specify options. Options may only be specified for variables of type %s.", err.VariableName, err.VariableType.String(), Enum) -} - -type TemplateFolderDoesNotExist string -func (err TemplateFolderDoesNotExist) Error() string { - return fmt.Sprintf("Folder %s does not exist", string(err)) -} - -type InvalidMissingKeyAction string -func (err InvalidMissingKeyAction) Error() string { - return fmt.Sprintf("Invalid MissingKeyAction '%s'. Value must be one of: %s", string(err), ALL_MISSING_KEY_ACTIONS) -} - -type InvalidMissingConfigAction string -func (err InvalidMissingConfigAction) Error() string { - return fmt.Sprintf("Invalid MissingConfigAction '%s'. Value must be one of: %s", string(err), ALL_MISSING_CONFIG_ACTIONS) -} - -type InvalidBoilerplateType string -func (err InvalidBoilerplateType) Error() string { - return fmt.Sprintf("Invalid InvalidBoilerplateType '%s'. Value must be one of: %s", string(err), ALL_BOILERPLATE_TYPES) -} - type BoilerplateConfigNotFound string func (err BoilerplateConfigNotFound) Error() string { return fmt.Sprintf("Could not find %s in %s and the %s flag is set to %s", BOILERPLATE_CONFIG_FILE, string(err), OPT_MISSING_CONFIG_ACTION, Exit) -} - -type MissingNameForDependency int -func (index MissingNameForDependency) Error() string { - return fmt.Sprintf("The name parameter was missing for dependency number %d", int(index) + 1) -} - -type DuplicateDependencyName string -func (name DuplicateDependencyName) Error() string { - return fmt.Sprintf("Found a duplicate dependency name: %s. All dependency names must be unique!", string(name)) -} - -type MissingTemplateFolderForDependency string -func (name MissingTemplateFolderForDependency) Error() string { - return fmt.Sprintf("The %s parameter was missing for dependency %s", OPT_TEMPLATE_FOLDER, string(name)) -} - -type MissingOutputFolderForDependency string -func (name MissingOutputFolderForDependency) Error() string { - return fmt.Sprintf("The %s parameter was missing for dependency %s", OPT_OUTPUT_FOLDER, string(name)) } \ No newline at end of file diff --git a/config/config_test.go b/config/config_test.go index 554e05a7..2f7bb964 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -28,7 +28,7 @@ func TestParseBoilerplateConfigInvalid(t *testing.T) { unwrapped := errors.Unwrap(err) _, isYamlTypeError := unwrapped.(*yaml.TypeError) - assert.True(t, isYamlTypeError, "Expected a YAML type error for an invalid yaml file but got %s", reflect.TypeOf(unwrapped)) + assert.True(t, isYamlTypeError, "Expected a YAML type error for an invalid yaml file but got %s: %v", reflect.TypeOf(unwrapped), unwrapped) } func TestParseBoilerplateConfigEmptyVariables(t *testing.T) { @@ -482,26 +482,4 @@ func TestLoadBoilerplateConfigInvalidConfig(t *testing.T) { unwrapped := errors.Unwrap(err) _, isYamlTypeError := unwrapped.(*yaml.TypeError) assert.True(t, isYamlTypeError, "Expected a YAML type error for an invalid yaml file but got %s", reflect.TypeOf(unwrapped)) -} - -func TestSplitIntoDependencyNameAndVariableName(t *testing.T) { - t.Parallel() - - testCases := []struct { - variableName string - expectedDependencyName string - expectedOriginalVariableName string - }{ - {"", "", ""}, - {"foo", "", "foo"}, - {"foo-bar baz_blah", "", "foo-bar baz_blah"}, - {"foo.bar", "foo", "bar"}, - {"foo.bar.baz", "foo", "bar.baz"}, - } - - for _, testCase := range testCases { - actualDependencyName, actualOriginalVariableName := SplitIntoDependencyNameAndVariableName(testCase.variableName) - assert.Equal(t, testCase.expectedDependencyName, actualDependencyName, "Variable name: %s", testCase.variableName) - assert.Equal(t, testCase.expectedOriginalVariableName, actualOriginalVariableName, "Variable name: %s", testCase.variableName) - } } \ No newline at end of file diff --git a/config/dependencies.go b/config/dependencies.go new file mode 100644 index 00000000..33f929b9 --- /dev/null +++ b/config/dependencies.go @@ -0,0 +1,75 @@ +package config + +import ( + "strings" + "github.com/gruntwork-io/boilerplate/errors" + "github.com/gruntwork-io/boilerplate/util" + "fmt" +) + +// A single boilerplate template that this boilerplate.yml depends on being executed first +type Dependency struct { + Name string + TemplateFolder string `yaml:"template-folder"` + OutputFolder string `yaml:"output-folder"` + DontInheritVariables bool `yaml:"dont-inherit-variables"` + Variables []Variable +} + +// Given a unique variable name, return a tuple that contains the dependency name (if any) and the variable name. +// Variable and dependency names are split by a dot, so for "foo.bar", this will return ("foo", "bar"). For just "foo", +// it will return ("", "foo"). +func SplitIntoDependencyNameAndVariableName(uniqueVariableName string) (string, string) { + parts := strings.SplitAfterN(uniqueVariableName, ".", 2) + if len(parts) == 2 { + // The split method leaves the character you split on at the end of the string, so we have to trim it + return strings.TrimSuffix(parts[0], "."), parts[1] + } else { + return "", parts[0] + } +} + +// Validate that the list of dependencies has reasonable contents and return an error if there is a problem +func validateDependencies(dependencies []Dependency) error { + dependencyNames := []string{} + for i, dependency := range dependencies { + if dependency.Name == "" { + return errors.WithStackTrace(MissingNameForDependency(i)) + } + if util.ListContains(dependency.Name, dependencyNames) { + return errors.WithStackTrace(DuplicateDependencyName(dependency.Name)) + } + dependencyNames = append(dependencyNames, dependency.Name) + + if dependency.TemplateFolder == "" { + return errors.WithStackTrace(MissingTemplateFolderForDependency(dependency.Name)) + } + if dependency.OutputFolder == "" { + return errors.WithStackTrace(MissingOutputFolderForDependency(dependency.Name)) + } + } + + return nil +} + +// Custom error types + +type MissingNameForDependency int +func (index MissingNameForDependency) Error() string { + return fmt.Sprintf("The name parameter was missing for dependency number %d", int(index) + 1) +} + +type DuplicateDependencyName string +func (name DuplicateDependencyName) Error() string { + return fmt.Sprintf("Found a duplicate dependency name: %s. All dependency names must be unique!", string(name)) +} + +type MissingTemplateFolderForDependency string +func (name MissingTemplateFolderForDependency) Error() string { + return fmt.Sprintf("The %s parameter was missing for dependency %s", OPT_TEMPLATE_FOLDER, string(name)) +} + +type MissingOutputFolderForDependency string +func (name MissingOutputFolderForDependency) Error() string { + return fmt.Sprintf("The %s parameter was missing for dependency %s", OPT_OUTPUT_FOLDER, string(name)) +} \ No newline at end of file diff --git a/config/dependencies_test.go b/config/dependencies_test.go new file mode 100644 index 00000000..6d531afc --- /dev/null +++ b/config/dependencies_test.go @@ -0,0 +1,28 @@ +package config + +import ( + "testing" + "github.com/stretchr/testify/assert" +) + +func TestSplitIntoDependencyNameAndVariableName(t *testing.T) { + t.Parallel() + + testCases := []struct { + variableName string + expectedDependencyName string + expectedOriginalVariableName string + }{ + {"", "", ""}, + {"foo", "", "foo"}, + {"foo-bar baz_blah", "", "foo-bar baz_blah"}, + {"foo.bar", "foo", "bar"}, + {"foo.bar.baz", "foo", "bar.baz"}, + } + + for _, testCase := range testCases { + actualDependencyName, actualOriginalVariableName := SplitIntoDependencyNameAndVariableName(testCase.variableName) + assert.Equal(t, testCase.expectedDependencyName, actualDependencyName, "Variable name: %s", testCase.variableName) + assert.Equal(t, testCase.expectedOriginalVariableName, actualOriginalVariableName, "Variable name: %s", testCase.variableName) + } +} diff --git a/config/options.go b/config/options.go new file mode 100644 index 00000000..63c30535 --- /dev/null +++ b/config/options.go @@ -0,0 +1,141 @@ +package config + +import ( + "github.com/gruntwork-io/boilerplate/errors" + "github.com/gruntwork-io/boilerplate/util" + "fmt" + "github.com/urfave/cli" +) + +// The command-line options for the boilerplate app +type BoilerplateOptions struct { + TemplateFolder string + OutputFolder string + NonInteractive bool + Vars map[string]interface{} + OnMissingKey MissingKeyAction + OnMissingConfig MissingConfigAction +} + +// Validate that the options have reasonable values and return an error if they don't +func (options *BoilerplateOptions) Validate() error { + if options.TemplateFolder == "" { + return errors.WithStackTrace(TemplateFolderOptionCannotBeEmpty) + } + + if !util.PathExists(options.TemplateFolder) { + return errors.WithStackTrace(TemplateFolderDoesNotExist(options.TemplateFolder)) + } + + if options.OutputFolder == "" { + return errors.WithStackTrace(OutputFolderOptionCannotBeEmpty) + } + + return nil +} + +// Parse the command line options provided by the user +func ParseOptions(cliContext *cli.Context) (*BoilerplateOptions, error) { + vars, err := parseVars(cliContext.StringSlice(OPT_VAR), cliContext.StringSlice(OPT_VAR_FILE)) + if err != nil { + return nil, err + } + + missingKeyAction := DEFAULT_MISSING_KEY_ACTION + missingKeyActionValue := cliContext.String(OPT_MISSING_KEY_ACTION) + if missingKeyActionValue != "" { + missingKeyAction, err = ParseMissingKeyAction(missingKeyActionValue) + if err != nil { + return nil, err + } + } + + missingConfigAction := DEFAULT_MISSING_CONFIG_ACTION + missingConfigActionValue := cliContext.String(OPT_MISSING_CONFIG_ACTION) + if missingConfigActionValue != "" { + missingConfigAction, err = ParseMissingConfigAction(missingConfigActionValue) + if err != nil { + return nil, err + } + } + + options := &BoilerplateOptions{ + TemplateFolder: cliContext.String(OPT_TEMPLATE_FOLDER), + OutputFolder: cliContext.String(OPT_OUTPUT_FOLDER), + NonInteractive: cliContext.Bool(OPT_NON_INTERACTIVE), + OnMissingKey: missingKeyAction, + OnMissingConfig: missingConfigAction, + Vars: vars, + } + + if err := options.Validate(); err != nil { + return nil, err + } + + return options, nil +} + +// This type is an enum that represents what we can do when a template looks up a missing key. This typically happens +// when there is a typo in the variable name in a template. +type MissingKeyAction string +var ( + Invalid = MissingKeyAction("invalid") // print for any missing key + ZeroValue = MissingKeyAction("zero") // print the zero value of the missing key + ExitWithError = MissingKeyAction("error") // exit with an error when there is a missing key +) + +var ALL_MISSING_KEY_ACTIONS = []MissingKeyAction{Invalid, ZeroValue, ExitWithError} +var DEFAULT_MISSING_KEY_ACTION = ExitWithError + +// Convert the given string to a MissingKeyAction enum, or return an error if this is not a valid value for the +// MissingKeyAction enum +func ParseMissingKeyAction(str string) (MissingKeyAction, error) { + for _, missingKeyAction := range ALL_MISSING_KEY_ACTIONS { + if string(missingKeyAction) == str { + return missingKeyAction, nil + } + } + return MissingKeyAction(""), errors.WithStackTrace(InvalidMissingKeyAction(str)) +} + +// This type is an enum that represents what to do when the template folder passed to boilerplate does not contain a +// boilerplate.yml file. +type MissingConfigAction string +var ( + Exit = MissingConfigAction("exit") + Ignore = MissingConfigAction("ignore") +) +var ALL_MISSING_CONFIG_ACTIONS = []MissingConfigAction{Exit, Ignore} +var DEFAULT_MISSING_CONFIG_ACTION = Exit + +// Convert the given string to a MissingConfigAction enum, or return an error if this is not a valid value for the +// MissingConfigAction enum +func ParseMissingConfigAction(str string) (MissingConfigAction, error) { + for _, missingConfigAction := range ALL_MISSING_CONFIG_ACTIONS { + if string(missingConfigAction) == str { + return missingConfigAction, nil + } + } + return MissingConfigAction(""), errors.WithStackTrace(InvalidMissingConfigAction(str)) +} + +// Custom error types + +var TemplateFolderOptionCannotBeEmpty = fmt.Errorf("The --%s option cannot be empty", OPT_TEMPLATE_FOLDER) +var OutputFolderOptionCannotBeEmpty = fmt.Errorf("The --%s option cannot be empty", OPT_OUTPUT_FOLDER) + +type TemplateFolderDoesNotExist string +func (err TemplateFolderDoesNotExist) Error() string { + return fmt.Sprintf("Folder %s does not exist", string(err)) +} + +type InvalidMissingKeyAction string +func (err InvalidMissingKeyAction) Error() string { + return fmt.Sprintf("Invalid MissingKeyAction '%s'. Value must be one of: %s", string(err), ALL_MISSING_KEY_ACTIONS) +} + +type InvalidMissingConfigAction string +func (err InvalidMissingConfigAction) Error() string { + return fmt.Sprintf("Invalid MissingConfigAction '%s'. Value must be one of: %s", string(err), ALL_MISSING_CONFIG_ACTIONS) +} + diff --git a/config/types.go b/config/types.go index d961655d..fea32df7 100644 --- a/config/types.go +++ b/config/types.go @@ -2,6 +2,7 @@ package config import ( "github.com/gruntwork-io/boilerplate/errors" + "fmt" ) // An enum that represents the types we support for boilerplate variables @@ -31,6 +32,15 @@ func ParseBoilerplateType(str string) (*BoilerplateType, error) { return nil, errors.WithStackTrace(InvalidBoilerplateType(str)) } +// Return a string representation of this Type func (boilerplateType BoilerplateType) String() string { return string(boilerplateType) -} \ No newline at end of file +} + +// Custom error types + +type InvalidBoilerplateType string +func (err InvalidBoilerplateType) Error() string { + return fmt.Sprintf("Invalid InvalidBoilerplateType '%s'. Value must be one of: %s", string(err), ALL_BOILERPLATE_TYPES) +} + diff --git a/config/variables.go b/config/variables.go index aa7e4bc9..77cbcbd4 100644 --- a/config/variables.go +++ b/config/variables.go @@ -4,10 +4,6 @@ import ( "fmt" "github.com/gruntwork-io/boilerplate/util" "github.com/gruntwork-io/boilerplate/errors" - "strings" - "io/ioutil" - "gopkg.in/yaml.v2" - "reflect" ) // A single variable defined in a boilerplate.yml config file @@ -30,6 +26,7 @@ func (variable Variable) FullName() string { } } +// Return a human-readable string representation of this variable func (variable Variable) String() string { return fmt.Sprintf("Variable {Name: '%s', Description: '%s', Type: '%s', Default: '%v', Options: '%v'}", variable.Name, variable.Description, variable.Type, variable.Default, variable.Options) } @@ -45,7 +42,7 @@ func (variable *Variable) UnmarshalYAML(unmarshal func(interface{}) error) error return err } - if unmarshalled, err := unmarshalVariable(fields); err != nil { + if unmarshalled, err := UnmarshalVariable(fields); err != nil { return err } else { *variable = *unmarshalled @@ -53,162 +50,6 @@ func (variable *Variable) UnmarshalYAML(unmarshal func(interface{}) error) error } } -func unmarshalVariable(fields map[string]interface{}) (*Variable, error) { - variable := Variable{} - var err error - - variable.Name, err = unmarshalStringField(fields, "name", true, "") - if err != nil { - return nil, err - } - - variable.Description, err = unmarshalStringField(fields, "description", false, variable.Name) - if err != nil { - return nil, err - } - - variable.Type, err = unmarshalTypeField(fields, "type", variable.Name) - if err != nil { - return nil, err - } - - variable.Options, err = unmarshalOptionsField(fields, "options", variable.Name, variable.Type) - if err != nil { - return nil, err - } - - variable.Default, err = unmarshalValue(fields["default"], variable) - if err != nil { - return nil, err - } - - return &variable, nil -} - -func unmarshalValue(value interface{}, variable Variable) (interface{}, error) { - if value == nil { - return nil, nil - } - - switch variable.Type { - case String: - if asString, isString := value.(string); isString { - return asString, nil - } - case Int: - if asInt, isInt := value.(int); isInt { - return asInt, nil - } - case Float: - if asFloat, isFloat := value.(float64); isFloat { - return asFloat, nil - } - case Bool: - if asBool, isBool := value.(bool); isBool { - return asBool, nil - } - case List: - if asList, isList := value.([]interface{}); isList { - return toStringList(asList), nil - } - case Map: - if asMap, isMap := value.(map[interface{}]interface{}); isMap { - return toStringMap(asMap), nil - } - case Enum: - if asString, isString := value.(string); isString { - for _, option := range variable.Options { - if asString == option { - return asString, nil - } - } - } - } - - return nil, InvalidVariableValue{Variable: variable, Value: value} -} - -func unmarshalOptionsField(fields map[string]interface{}, fieldName string, variableName string, variableType BoilerplateType) ([]string, error) { - options, hasOptions := fields[fieldName] - - if !hasOptions { - if variableType == Enum { - return nil, errors.WithStackTrace(VariableMissingOptions(variableName)) - } else { - return nil, nil - } - } - - if variableType != Enum { - return nil, errors.WithStackTrace(OptionsCanOnlyBeUsedWithEnum{VariableName: variableName, VariableType: variableType}) - } - - optionsAsList, isList := options.([]interface{}) - if !isList { - return nil, errors.WithStackTrace(InvalidTypeForField{FieldName: fieldName, ExpectedType: "List", ActualType: reflect.TypeOf(options).String(), VariableName: variableName}) - } - - return toStringList(optionsAsList), nil -} - -func toStringList(genericList []interface{}) []string { - stringList := []string{} - - for _, value := range genericList { - stringList = append(stringList, toString(value)) - } - - return stringList -} - -func toStringMap(genericMap map[interface{}]interface{}) map[string]string { - stringMap := map[string]string{} - - for key, value := range genericMap { - stringMap[toString(key)] = toString(value) - } - - return stringMap -} - -func toString(value interface{}) string { - return fmt.Sprintf("%v", value) -} - -func unmarshalTypeField(fields map[string]interface{}, fieldName string, variableName string) (BoilerplateType, error) { - variableTypeAsString, err := unmarshalStringField(fields, fieldName, false, variableName) - if err != nil { - return BOILERPLATE_TYPE_DEFAULT, err - } - - if variableTypeAsString != "" { - variableType, err := ParseBoilerplateType(variableTypeAsString) - if err != nil { - return BOILERPLATE_TYPE_DEFAULT, err - } - return *variableType, nil - } - - return BOILERPLATE_TYPE_DEFAULT, nil -} - -func unmarshalStringField(fields map[string]interface{}, fieldName string, requiredField bool, variableName string) (string, error) { - value, hasValue := fields[fieldName] - if !hasValue { - if requiredField { - return "", errors.WithStackTrace(RequiredFieldMissing(fieldName)) - } else { - return "", nil - } - } - - if valueAsString, isString := value.(string); isString { - return valueAsString, nil - } else { - return "", errors.WithStackTrace(InvalidTypeForField{FieldName: fieldName, ExpectedType: "string", ActualType: reflect.TypeOf(value).String(), VariableName: variableName}) - } -} - // Get a value for each of the variables specified in boilerplateConfig, other than those already in existingVariables. // The value for a variable can come from the user (if the non-interactive option isn't set), the default value in the // config, or a command line option. @@ -232,7 +73,7 @@ func GetVariables(options *BoilerplateOptions, boilerplateConfig *BoilerplateCon } } - variables[variable.Name], err = unmarshalValue(value, variable) + variables[variable.Name], err = UnmarshalVariableValue(value, variable) if err != nil { return variables, err } @@ -312,120 +153,9 @@ func getVariableFromUser(variable Variable, options *BoilerplateOptions) (interf return parseYamlString(value) } -// Parse a list of NAME=VALUE pairs into a map. -func ParseVariablesFromKeyValuePairs(varsList []string) (map[string]interface{}, error) { - vars := map[string]interface{}{} - - for _, variable := range varsList { - variableParts := strings.Split(variable, "=") - if len(variableParts) != 2 { - return vars, errors.WithStackTrace(InvalidVarSyntax(variable)) - } - - key := variableParts[0] - value := variableParts[1] - if key == "" { - return vars, errors.WithStackTrace(VariableNameCannotBeEmpty(variable)) - } - - parsedValue, err := parseYamlString(value) - if err != nil { - return vars, err - } - - vars[key] = parsedValue - } - - return vars, nil -} - -// Parse a YAML string into a Go type -func parseYamlString(str string) (interface{}, error) { - var parsedValue interface{} - - err := yaml.Unmarshal([]byte(str), &parsedValue) - if err != nil { - return nil, errors.WithStackTrace(err) - } - - return parsedValue, nil -} - -// Parse a list of YAML files that define variables into a map. -func ParseVariablesFromVarFiles(varFileList []string) (map[string]interface{}, error) { - vars := map[string]interface{}{} - - for _, varFile := range varFileList { - varsInFile, err := ParseVariablesFromVarFile(varFile) - if err != nil { - return vars, err - } - vars = util.MergeMaps(vars, varsInFile) - } - - return vars, nil -} - -// Parse the NAME: VALUE pairs in the given YAML file into a map -func ParseVariablesFromVarFile(varFilePath string) (map[string]interface{}, error) { - bytes, err := ioutil.ReadFile(varFilePath) - if err != nil { - return map[string]interface{}{}, errors.WithStackTrace(err) - } - return parseVariablesFromVarFileContents(bytes) -} - -// Parse the NAME: VALUE pairs in the given YAML file contents into a map -func parseVariablesFromVarFileContents(varFileContents []byte)(map[string]interface{}, error) { - vars := map[string]interface{}{} - - err := yaml.Unmarshal(varFileContents, &vars) - if err != nil { - return vars, errors.WithStackTrace(err) - } - - return vars, nil -} - // Custom error types type MissingVariableWithNonInteractiveMode string func (variableName MissingVariableWithNonInteractiveMode) Error() string { return fmt.Sprintf("Variable '%s' does not have a default, no value was specified at the command line using the --%s option, and the --%s flag is set, so cannot prompt user for a value.", string(variableName), OPT_VAR, OPT_NON_INTERACTIVE) -} - -type InvalidVarSyntax string -func (varSyntax InvalidVarSyntax) Error() string { - return fmt.Sprintf("Invalid syntax for variable. Expected NAME=VALUE but got %s", string(varSyntax)) -} - -type VariableNameCannotBeEmpty string -func (varSyntax VariableNameCannotBeEmpty) Error() string { - return fmt.Sprintf("Variable name cannot be empty. Expected NAME=VALUE but got %s", string(varSyntax)) -} - -type InvalidVariableValue struct { - Value interface{} - Variable Variable -} -func (err InvalidVariableValue) Error() string { - message := fmt.Sprintf("Value '%v' is not a valid value for variable '%s' with type '%s'.", err.Value, err.Variable.Name, err.Variable.Type.String()) - if err.Variable.Type == Enum { - message = fmt.Sprintf("%s. Value must be one of: %s.", message, err.Variable.Options) - } - return message -} - -type InvalidTypeForField struct { - FieldName string - VariableName string - ExpectedType string - ActualType string -} -func (err InvalidTypeForField) Error() string { - message := fmt.Sprintf("%s must have type %s but got %s", err.FieldName, err.ExpectedType, err.ActualType) - if err.VariableName != "" { - message = fmt.Sprintf("%s for variable %s", message, err.VariableName) - } - return message } \ No newline at end of file diff --git a/config/variables_from_yaml.go b/config/variables_from_yaml.go new file mode 100644 index 00000000..feb20cc4 --- /dev/null +++ b/config/variables_from_yaml.go @@ -0,0 +1,308 @@ +package config + +import ( + "github.com/gruntwork-io/boilerplate/errors" + "reflect" + "gopkg.in/yaml.v2" + "io/ioutil" + "github.com/gruntwork-io/boilerplate/util" + "strings" + "fmt" +) + +// Given a map where the keys are the fields of a boilerplate Variable, this method crates a Variable struct with those +// fields filled in with proper types. This method also validates all the fields and returns an error if any problems +// are found. +func UnmarshalVariable(fields map[string]interface{}) (*Variable, error) { + variable := Variable{} + var err error + + variable.Name, err = unmarshalStringField(fields, "name", true, "") + if err != nil { + return nil, err + } + + variable.Description, err = unmarshalStringField(fields, "description", false, variable.Name) + if err != nil { + return nil, err + } + + variable.Type, err = unmarshalTypeField(fields, "type", variable.Name) + if err != nil { + return nil, err + } + + variable.Options, err = unmarshalOptionsField(fields, "options", variable.Name, variable.Type) + if err != nil { + return nil, err + } + + variable.Default, err = UnmarshalVariableValue(fields["default"], variable) + if err != nil { + return nil, err + } + + return &variable, nil +} + +// Convert the given value to the proper type for the given variable, or return an error if the type doesn't match. +// For example, if this variable is of type List, then the returned value will be a list of strings. +func UnmarshalVariableValue(value interface{}, variable Variable) (interface{}, error) { + if value == nil { + return nil, nil + } + + switch variable.Type { + case String: + if asString, isString := value.(string); isString { + return asString, nil + } + case Int: + if asInt, isInt := value.(int); isInt { + return asInt, nil + } + case Float: + if asFloat, isFloat := value.(float64); isFloat { + return asFloat, nil + } + case Bool: + if asBool, isBool := value.(bool); isBool { + return asBool, nil + } + case List: + if asList, isList := value.([]interface{}); isList { + return util.ToStringList(asList), nil + } + case Map: + if asMap, isMap := value.(map[interface{}]interface{}); isMap { + return util.ToStringMap(asMap), nil + } + case Enum: + if asString, isString := value.(string); isString { + for _, option := range variable.Options { + if asString == option { + return asString, nil + } + } + } + } + + return nil, InvalidVariableValue{Variable: variable, Value: value} +} + +// Extract the options field from the given map of fields using the given field name and convert those options to a +// list of strings. +func unmarshalOptionsField(fields map[string]interface{}, fieldName string, variableName string, variableType BoilerplateType) ([]string, error) { + options, hasOptions := fields[fieldName] + + if !hasOptions { + if variableType == Enum { + return nil, errors.WithStackTrace(VariableMissingOptions(variableName)) + } else { + return nil, nil + } + } + + if variableType != Enum { + return nil, errors.WithStackTrace(OptionsCanOnlyBeUsedWithEnum{VariableName: variableName, VariableType: variableType}) + } + + optionsAsList, isList := options.([]interface{}) + if !isList { + return nil, errors.WithStackTrace(InvalidTypeForField{FieldName: fieldName, ExpectedType: "List", ActualType: reflect.TypeOf(options).String(), VariableName: variableName}) + } + + return util.ToStringList(optionsAsList), nil +} + +// Extract the type field from the map of fields using the given field name and convert the type to a BoilerplateType. +func unmarshalTypeField(fields map[string]interface{}, fieldName string, variableName string) (BoilerplateType, error) { + variableTypeAsString, err := unmarshalStringField(fields, fieldName, false, variableName) + if err != nil { + return BOILERPLATE_TYPE_DEFAULT, err + } + + if variableTypeAsString != "" { + variableType, err := ParseBoilerplateType(variableTypeAsString) + if err != nil { + return BOILERPLATE_TYPE_DEFAULT, err + } + return *variableType, nil + } + + return BOILERPLATE_TYPE_DEFAULT, nil +} + +// Extract a string field from the map of fields using the given field name and convert it to a string. If no such +// field is in the map of fields but requiredField is set to true, return an error. +func unmarshalStringField(fields map[string]interface{}, fieldName string, requiredField bool, variableName string) (string, error) { + value, hasValue := fields[fieldName] + if !hasValue { + if requiredField { + return "", errors.WithStackTrace(RequiredFieldMissing(fieldName)) + } else { + return "", nil + } + } + + if valueAsString, isString := value.(string); isString { + return valueAsString, nil + } else { + return "", errors.WithStackTrace(InvalidTypeForField{FieldName: fieldName, ExpectedType: "string", ActualType: reflect.TypeOf(value).String(), VariableName: variableName}) + } +} + +// Parse a list of NAME=VALUE pairs passed in as command-line options into a map of variable names to variable values. +// Along the way, each value is parsed as YAML. +func parseVariablesFromKeyValuePairs(varsList []string) (map[string]interface{}, error) { + vars := map[string]interface{}{} + + for _, variable := range varsList { + variableParts := strings.Split(variable, "=") + if len(variableParts) != 2 { + return vars, errors.WithStackTrace(InvalidVarSyntax(variable)) + } + + key := variableParts[0] + value := variableParts[1] + if key == "" { + return vars, errors.WithStackTrace(VariableNameCannotBeEmpty(variable)) + } + + parsedValue, err := parseYamlString(value) + if err != nil { + return vars, err + } + + vars[key] = parsedValue + } + + return vars, nil +} + +// Parse a YAML string into a Go type +func parseYamlString(str string) (interface{}, error) { + var parsedValue interface{} + + err := yaml.Unmarshal([]byte(str), &parsedValue) + if err != nil { + return nil, errors.WithStackTrace(err) + } + + return parsedValue, nil +} + +// Parse a list of YAML files that define variables into a map from variable name to variable value. Along the way, +// each value is parsed as YAML. +func parseVariablesFromVarFiles(varFileList []string) (map[string]interface{}, error) { + vars := map[string]interface{}{} + + for _, varFile := range varFileList { + varsInFile, err := ParseVariablesFromVarFile(varFile) + if err != nil { + return vars, err + } + vars = util.MergeMaps(vars, varsInFile) + } + + return vars, nil +} + +// Parse the variables in the given YAML file into a map of variable name to variable value. Along the way, each value +// is parsed as YAML. +func ParseVariablesFromVarFile(varFilePath string) (map[string]interface{}, error) { + bytes, err := ioutil.ReadFile(varFilePath) + if err != nil { + return map[string]interface{}{}, errors.WithStackTrace(err) + } + return parseVariablesFromVarFileContents(bytes) +} + +// Parse the variables in the given YAML contents into a map of variable name to variable value. Along the way, each +// value is parsed as YAML. +func parseVariablesFromVarFileContents(varFileContents []byte)(map[string]interface{}, error) { + vars := map[string]interface{}{} + + err := yaml.Unmarshal(varFileContents, &vars) + if err != nil { + return vars, errors.WithStackTrace(err) + } + + return vars, nil +} + + +// Parse variables passed in via command line options, either as a list of NAME=VALUE variable pairs in varsList, or a +// list of paths to YAML files that define NAME: VALUE pairs. Return a map of the NAME: VALUE pairs. Along the way, +// each VALUE is parsed as YAML. +func parseVars(varsList []string, varFileList[]string) (map[string]interface{}, error) { + variables := map[string]interface{}{} + + varsFromVarsList, err := parseVariablesFromKeyValuePairs(varsList) + if err != nil { + return variables, err + } + + varsFromVarFiles, err := parseVariablesFromVarFiles(varFileList) + if err != nil { + return variables, err + } + + return util.MergeMaps(varsFromVarsList, varsFromVarFiles), nil +} + +// Custom error types + +type VariableMissingOptions string +func (err VariableMissingOptions) Error() string { + return fmt.Sprintf("Variable %s has type %s but does not specify any options. You must specify at least one option.", string(err), Enum) +} + +type InvalidVariableValue struct { + Value interface{} + Variable Variable +} +func (err InvalidVariableValue) Error() string { + message := fmt.Sprintf("Value '%v' is not a valid value for variable '%s' with type '%s'.", err.Value, err.Variable.Name, err.Variable.Type.String()) + if err.Variable.Type == Enum { + message = fmt.Sprintf("%s. Value must be one of: %s.", message, err.Variable.Options) + } + return message +} + +type OptionsCanOnlyBeUsedWithEnum struct { + VariableName string + VariableType BoilerplateType +} +func (err OptionsCanOnlyBeUsedWithEnum) Error() string { + return fmt.Sprintf("Variable %s has type %s and tries to specify options. Options may only be specified for variables of type %s.", err.VariableName, err.VariableType.String(), Enum) +} + +type InvalidTypeForField struct { + FieldName string + VariableName string + ExpectedType string + ActualType string +} +func (err InvalidTypeForField) Error() string { + message := fmt.Sprintf("%s must have type %s but got %s", err.FieldName, err.ExpectedType, err.ActualType) + if err.VariableName != "" { + message = fmt.Sprintf("%s for variable %s", message, err.VariableName) + } + return message +} + +type VariableNameCannotBeEmpty string +func (varSyntax VariableNameCannotBeEmpty) Error() string { + return fmt.Sprintf("Variable name cannot be empty. Expected NAME=VALUE but got %s", string(varSyntax)) +} + +type InvalidVarSyntax string +func (varSyntax InvalidVarSyntax) Error() string { + return fmt.Sprintf("Invalid syntax for variable. Expected NAME=VALUE but got %s", string(varSyntax)) +} + +type RequiredFieldMissing string +func (err RequiredFieldMissing) Error() string { + return fmt.Sprintf("Variable is missing required field %s", string(err)) +} \ No newline at end of file diff --git a/config/variables_from_yaml_test.go b/config/variables_from_yaml_test.go new file mode 100644 index 00000000..3e83dcd9 --- /dev/null +++ b/config/variables_from_yaml_test.go @@ -0,0 +1,79 @@ +package config + +import ( + "testing" + "reflect" + "github.com/stretchr/testify/assert" + "github.com/gruntwork-io/boilerplate/errors" + "gopkg.in/yaml.v2" +) + +const YAML_FILE_ONE_VAR = +` +key: value +` + +const YAML_FILE_MULTIPLE_VARS = +` +key1: value1 +key2: value2 +key3: value3 +` + +func TestParseVariablesFromVarFileContents(t *testing.T) { + t.Parallel() + + testCases := []struct { + fileContents string + expectYamlTypeError bool + expectedVars map[string]interface{} + }{ + {"", false, map[string]interface{}{}}, + {YAML_FILE_ONE_VAR, false, map[string]interface{}{"key": "value"}}, + {YAML_FILE_MULTIPLE_VARS, false, map[string]interface{}{"key1": "value1", "key2": "value2", "key3": "value3"}}, + {"invalid yaml", true, map[string]interface{}{}}, + } + + for _, testCase := range testCases { + actualVars, err := parseVariablesFromVarFileContents([]byte(testCase.fileContents)) + if testCase.expectYamlTypeError { + assert.NotNil(t, err) + unwrapped := errors.Unwrap(err) + _, isYamlTypeError := unwrapped.(*yaml.TypeError) + assert.True(t, isYamlTypeError, "Expected a YAML type error for an invalid yaml file but got %s", reflect.TypeOf(unwrapped)) + } else { + assert.Nil(t, err, "Got unexpected error: %v", err) + assert.Equal(t, testCase.expectedVars, actualVars) + } + } +} + + +func TestParseVariablesFromKeyValuePairs(t *testing.T) { + t.Parallel() + + testCases := []struct { + keyValuePairs []string + expectedError error + expectedVars map[string]interface{} + }{ + {[]string{}, nil, map[string]interface{}{}}, + {[]string{"key=value"}, nil, map[string]interface{}{"key": "value"}}, + {[]string{"key="}, nil, map[string]interface{}{"key": nil}}, + {[]string{"key1=value1", "key2=value2", "key3=value3"}, nil, map[string]interface{}{"key1": "value1", "key2": "value2", "key3": "value3"}}, + {[]string{"invalidsyntax"}, InvalidVarSyntax("invalidsyntax"), map[string]interface{}{}}, + {[]string{"="}, VariableNameCannotBeEmpty("="), map[string]interface{}{}}, + {[]string{"=foo"}, VariableNameCannotBeEmpty("=foo"), map[string]interface{}{}}, + } + + for _, testCase := range testCases { + actualVars, err := parseVariablesFromKeyValuePairs(testCase.keyValuePairs) + if testCase.expectedError == nil { + assert.Nil(t, err) + assert.Equal(t, testCase.expectedVars, actualVars) + } else { + assert.NotNil(t, err) + assert.True(t, errors.IsError(err, testCase.expectedError), "Expected an error of type '%s' with value '%s' but got an error of type '%s' with value '%s'", reflect.TypeOf(testCase.expectedError), testCase.expectedError.Error(), reflect.TypeOf(err), err.Error()) + } + } +} \ No newline at end of file diff --git a/config/variables_test.go b/config/variables_test.go index a87aa93d..fd9cea07 100644 --- a/config/variables_test.go +++ b/config/variables_test.go @@ -5,7 +5,6 @@ import ( "github.com/stretchr/testify/assert" "reflect" "github.com/gruntwork-io/boilerplate/errors" - "gopkg.in/yaml.v2" ) func TestGetVariableFromVarsEmptyVars(t *testing.T) { @@ -224,71 +223,4 @@ func TestGetVariablesMatchFromVarsAndDefaults(t *testing.T) { assert.Equal(t, expected, actual) } -func TestParseVariablesFromKeyValuePairs(t *testing.T) { - t.Parallel() - - testCases := []struct { - keyValuePairs []string - expectedError error - expectedVars map[string]interface{} - }{ - {[]string{}, nil, map[string]interface{}{}}, - {[]string{"key=value"}, nil, map[string]interface{}{"key": "value"}}, - {[]string{"key="}, nil, map[string]interface{}{"key": nil}}, - {[]string{"key1=value1", "key2=value2", "key3=value3"}, nil, map[string]interface{}{"key1": "value1", "key2": "value2", "key3": "value3"}}, - {[]string{"invalidsyntax"}, InvalidVarSyntax("invalidsyntax"), map[string]interface{}{}}, - {[]string{"="}, VariableNameCannotBeEmpty("="), map[string]interface{}{}}, - {[]string{"=foo"}, VariableNameCannotBeEmpty("=foo"), map[string]interface{}{}}, - } - - for _, testCase := range testCases { - actualVars, err := ParseVariablesFromKeyValuePairs(testCase.keyValuePairs) - if testCase.expectedError == nil { - assert.Nil(t, err) - assert.Equal(t, testCase.expectedVars, actualVars) - } else { - assert.NotNil(t, err) - assert.True(t, errors.IsError(err, testCase.expectedError), "Expected an error of type '%s' with value '%s' but got an error of type '%s' with value '%s'", reflect.TypeOf(testCase.expectedError), testCase.expectedError.Error(), reflect.TypeOf(err), err.Error()) - } - } -} - -const YAML_FILE_ONE_VAR = -` -key: value -` -const YAML_FILE_MULTIPLE_VARS = -` -key1: value1 -key2: value2 -key3: value3 -` - -func TestParseVariablesFromVarFileContents(t *testing.T) { - t.Parallel() - - testCases := []struct { - fileContents string - expectYamlTypeError bool - expectedVars map[string]interface{} - }{ - {"", false, map[string]interface{}{}}, - {YAML_FILE_ONE_VAR, false, map[string]interface{}{"key": "value"}}, - {YAML_FILE_MULTIPLE_VARS, false, map[string]interface{}{"key1": "value1", "key2": "value2", "key3": "value3"}}, - {"invalid yaml", true, map[string]interface{}{}}, - } - - for _, testCase := range testCases { - actualVars, err := parseVariablesFromVarFileContents([]byte(testCase.fileContents)) - if testCase.expectYamlTypeError { - assert.NotNil(t, err) - unwrapped := errors.Unwrap(err) - _, isYamlTypeError := unwrapped.(*yaml.TypeError) - assert.True(t, isYamlTypeError, "Expected a YAML type error for an invalid yaml file but got %s", reflect.TypeOf(unwrapped)) - } else { - assert.Nil(t, err) - assert.Equal(t, testCase.expectedVars, actualVars) - } - } -} \ No newline at end of file diff --git a/util/collections.go b/util/collections.go index 5bb10997..32daa3ea 100644 --- a/util/collections.go +++ b/util/collections.go @@ -1,5 +1,7 @@ package util +import "fmt" + // Merge all the maps into one. Sadly, Go has no generics, so this is only defined for string to interface maps. func MergeMaps(maps ... map[string]interface{}) map[string]interface{} { out := map[string]interface{}{} @@ -22,4 +24,31 @@ func ListContains(needle string, haystack []string) bool { } return false +} + +// Convert a generic list to a list of strings +func ToStringList(genericList []interface{}) []string { + stringList := []string{} + + for _, value := range genericList { + stringList = append(stringList, ToString(value)) + } + + return stringList +} + +// Convert a generic map to a map from string to string +func ToStringMap(genericMap map[interface{}]interface{}) map[string]string { + stringMap := map[string]string{} + + for key, value := range genericMap { + stringMap[ToString(key)] = ToString(value) + } + + return stringMap +} + +// Convert a single value to its string representation +func ToString(value interface{}) string { + return fmt.Sprintf("%v", value) } \ No newline at end of file From 72b021ba4d929346a608bc9fd80a12cdc443d3e0 Mon Sep 17 00:00:00 2001 From: Yevgeniy Brikman Date: Mon, 19 Sep 2016 23:48:07 +0100 Subject: [PATCH 4/7] Refactor Variable into an interface in a separate package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I had a number of bugs related to how the Variable type was being created where, as we added new fields, some of the old places using that variable were not providing reasonable defaults for those new fields. Moving it into a separate package, turning it into an interface, and making the implementation private allows us to precisely control “constructor” usage and ensure all necessary defaults are set. It also provides a cleaner API for working with the Variable class. --- config/config.go | 47 ++-- config/config_test.go | 164 ++++++++---- config/dependencies.go | 75 ------ config/get_variables.go | 119 +++++++++ ...ariables_test.go => get_variables_test.go} | 33 +-- config/options.go | 3 +- config/variables.go | 161 ------------ templates/template_processor.go | 13 +- templates/template_processor_test.go | 19 +- util/collections.go | 14 +- variables/dependencies.go | 109 ++++++++ {config => variables}/dependencies_test.go | 5 +- {config => variables}/types.go | 3 +- variables/variables.go | 243 ++++++++++++++++++ variables/variables_test.go | 1 + .../yaml_helpers.go | 174 +++++-------- .../yaml_helpers_test.go | 4 +- 17 files changed, 748 insertions(+), 439 deletions(-) delete mode 100644 config/dependencies.go create mode 100644 config/get_variables.go rename config/{variables_test.go => get_variables_test.go} (84%) delete mode 100644 config/variables.go create mode 100644 variables/dependencies.go rename {config => variables}/dependencies_test.go (97%) rename {config => variables}/types.go (98%) create mode 100644 variables/variables.go create mode 100644 variables/variables_test.go rename config/variables_from_yaml.go => variables/yaml_helpers.go (57%) rename config/variables_from_yaml_test.go => variables/yaml_helpers_test.go (99%) diff --git a/config/config.go b/config/config.go index 6bf6a9e9..94d40ad8 100644 --- a/config/config.go +++ b/config/config.go @@ -7,6 +7,7 @@ import ( "github.com/gruntwork-io/boilerplate/util" "fmt" "github.com/gruntwork-io/boilerplate/errors" + "github.com/gruntwork-io/boilerplate/variables" ) const BOILERPLATE_CONFIG_FILE = "boilerplate.yml" @@ -21,8 +22,37 @@ const OPT_MISSING_CONFIG_ACTION = "missing-config-action" // The contents of a boilerplate.yml config file type BoilerplateConfig struct { - Variables []Variable - Dependencies []Dependency + Variables []variables.Variable + Dependencies []variables.Dependency +} + +// Implement the go-yaml unmarshal interface for BoilerplateConfig. We can't let go-yaml handle this itself because we +// need to: +// +// 1. Set Defaults for missing fields (e.g. Type) +// 2. Validate the type corresponds to the Default value +// 3. Validate Options are only specified for the Enum Type +func (config *BoilerplateConfig) UnmarshalYAML(unmarshal func(interface{}) error) error { + var fields map[string]interface{} + if err := unmarshal(&fields); err != nil { + return err + } + + vars, err := variables.UnmarshalVariables(fields, "variables") + if err != nil { + return err + } + + deps, err := variables.UnmarshalDependencies(fields, "dependencies") + if err != nil { + return err + } + + *config = BoilerplateConfig{ + Variables: vars, + Dependencies: deps, + } + return nil } // Load the boilerplate.yml config contents for the folder specified in the given options @@ -53,10 +83,6 @@ func ParseBoilerplateConfig(configContents []byte) (*BoilerplateConfig, error) { return nil, errors.WithStackTrace(err) } - if err := boilerplateConfig.validate(); err != nil { - return nil, err - } - return boilerplateConfig, nil } @@ -65,15 +91,6 @@ func BoilerplateConfigPath(templateFolder string) string { return path.Join(templateFolder, BOILERPLATE_CONFIG_FILE) } -// Validate that the config file has reasonable contents and return an error if there is a problem -func (boilerplateConfig BoilerplateConfig) validate() error { - if err := validateDependencies(boilerplateConfig.Dependencies); err != nil { - return err - } - - return nil -} - // Custom error types type BoilerplateConfigNotFound string diff --git a/config/config_test.go b/config/config_test.go index 2f7bb964..0f3c57e2 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -7,6 +7,7 @@ import ( "gopkg.in/yaml.v2" "github.com/gruntwork-io/boilerplate/errors" "path" + "github.com/gruntwork-io/boilerplate/variables" ) func TestParseBoilerplateConfigEmpty(t *testing.T) { @@ -31,13 +32,20 @@ func TestParseBoilerplateConfigInvalid(t *testing.T) { assert.True(t, isYamlTypeError, "Expected a YAML type error for an invalid yaml file but got %s: %v", reflect.TypeOf(unwrapped), unwrapped) } -func TestParseBoilerplateConfigEmptyVariables(t *testing.T) { - t.Parallel() +// YAML is whitespace sensitive, so we need to be careful that we don't introduce unnecessary indentation +const CONFIG_EMPTY_VARIABLES_AND_DEPENDENCIES = +`variables: +dependencies: +` - configContents := `variables:` +func TestParseBoilerplateConfigEmptyVariablesAndDependencies(t *testing.T) { + t.Parallel() - actual, err := ParseBoilerplateConfig([]byte(configContents)) - expected := &BoilerplateConfig{} + actual, err := ParseBoilerplateConfig([]byte(CONFIG_EMPTY_VARIABLES_AND_DEPENDENCIES)) + expected := &BoilerplateConfig{ + Variables: []variables.Variable{}, + Dependencies: []variables.Dependency{}, + } assert.Nil(t, err) assert.Equal(t, expected, actual) @@ -54,9 +62,10 @@ func TestParseBoilerplateConfigOneVariableMinimal(t *testing.T) { actual, err := ParseBoilerplateConfig([]byte(CONFIG_ONE_VARIABLE_MINIMAL)) expected := &BoilerplateConfig{ - Variables: []Variable{ - {Name: "foo", Type: String}, + Variables: []variables.Variable{ + variables.NewStringVariable("foo"), }, + Dependencies: []variables.Dependency{}, } assert.Nil(t, err) @@ -77,9 +86,10 @@ func TestParseBoilerplateConfigOneVariableFull(t *testing.T) { actual, err := ParseBoilerplateConfig([]byte(CONFIG_ONE_VARIABLE_FULL)) expected := &BoilerplateConfig{ - Variables: []Variable{ - {Name: "foo", Description: "example description", Default: "default", Type: String}, + Variables: []variables.Variable{ + variables.NewStringVariable("foo").WithDescription("example description").WithDefault("default"), }, + Dependencies: []variables.Dependency{}, } assert.Nil(t, err) @@ -99,7 +109,7 @@ func TestParseBoilerplateConfigOneVariableMissingName(t *testing.T) { _, err := ParseBoilerplateConfig([]byte(CONFIG_ONE_VARIABLE_MISSING_NAME)) assert.NotNil(t, err) - assert.True(t, errors.IsError(err, RequiredFieldMissing("name")), "Expected a RequiredFieldMissing error but got %s", reflect.TypeOf(err)) + assert.True(t, errors.IsError(err, variables.RequiredFieldMissing("name")), "Expected a RequiredFieldMissing error but got %s: %v", reflect.TypeOf(errors.Unwrap(err)), err) } // YAML is whitespace sensitive, so we need to be careful that we don't introduce unnecessary indentation @@ -115,7 +125,24 @@ func TestParseBoilerplateConfigOneVariableInvalidType(t *testing.T) { _, err := ParseBoilerplateConfig([]byte(CONFIG_ONE_VARIABLE_INVALID_TYPE)) assert.NotNil(t, err) - assert.True(t, errors.IsError(err, InvalidBoilerplateType("foo")), "Expected a InvalidBoilerplateType error but got %s", reflect.TypeOf(err)) + assert.True(t, errors.IsError(err, variables.InvalidBoilerplateType("foo")), "Expected a InvalidBoilerplateType error but got %s", reflect.TypeOf(errors.Unwrap(err))) +} + +// YAML is whitespace sensitive, so we need to be careful that we don't introduce unnecessary indentation +const CONFIG_ONE_VARIABLE_INVALID_TYPE_FOR_NAME_FIELD = +`variables: + - name: + - foo + - bar +` + +func TestParseBoilerplateConfigInvalidTypeForNameField(t *testing.T) { + t.Parallel() + + _, err := ParseBoilerplateConfig([]byte(CONFIG_ONE_VARIABLE_INVALID_TYPE_FOR_NAME_FIELD)) + + assert.NotNil(t, err) + assert.True(t, errors.IsError(err, variables.InvalidTypeForField{FieldName: "name", ExpectedType: "string", ActualType: reflect.TypeOf([]interface{}{})}), "Expected a InvalidTypeForField error but got %s: %v", reflect.TypeOf(errors.Unwrap(err)), err) } // YAML is whitespace sensitive, so we need to be careful that we don't introduce unnecessary indentation @@ -131,7 +158,46 @@ func TestParseBoilerplateConfigOneVariableEnumNoOptions(t *testing.T) { _, err := ParseBoilerplateConfig([]byte(CONFIG_ONE_VARIABLE_ENUM_NO_OPTIONS)) assert.NotNil(t, err) - assert.True(t, errors.IsError(err, VariableMissingOptions("foo")), "Expected a VariableMissingOptions error but got %s", reflect.TypeOf(err)) + assert.True(t, errors.IsError(err, variables.OptionsMissing("foo")), "Expected a VariableMissingOptions error but got %s", reflect.TypeOf(errors.Unwrap(err))) +} + +// YAML is whitespace sensitive, so we need to be careful that we don't introduce unnecessary indentation +const CONFIG_ONE_VARIABLE_ENUM_OPTIONS_WRONG_TYPE = +`variables: + - name: foo + type: enum + options: foo +` + +func TestParseBoilerplateConfigOneVariableEnumWrongType(t *testing.T) { + t.Parallel() + + _, err := ParseBoilerplateConfig([]byte(CONFIG_ONE_VARIABLE_ENUM_OPTIONS_WRONG_TYPE)) + + assert.NotNil(t, err) + assert.True(t, errors.IsError(err, variables.InvalidTypeForField{FieldName: "options", ExpectedType: "List", ActualType: reflect.TypeOf("string"), Context: "foo"}), "Expected a InvalidTypeForField error but got %s", reflect.TypeOf(errors.Unwrap(err))) +} + +// YAML is whitespace sensitive, so we need to be careful that we don't introduce unnecessary indentation +const CONFIG_ONE_VARIABLE_ENUM_INVALID_DEFAULT = +`variables: + - name: foo + type: enum + options: + - foo + - bar + - baz + default: invalid +` + +func TestParseBoilerplateConfigOneVariableEnumInvalidDefault(t *testing.T) { + t.Parallel() + + _, err := ParseBoilerplateConfig([]byte(CONFIG_ONE_VARIABLE_ENUM_INVALID_DEFAULT)) + + assert.NotNil(t, err) + _, isInvalidVariableValueErr := errors.Unwrap(err).(variables.InvalidVariableValue) + assert.True(t, isInvalidVariableValueErr, "Expected a InvalidVariableValue error but got %s", reflect.TypeOf(errors.Unwrap(err))) } // YAML is whitespace sensitive, so we need to be careful that we don't introduce unnecessary indentation @@ -149,7 +215,7 @@ func TestParseBoilerplateConfigOneVariableOptionsForNonEnum(t *testing.T) { _, err := ParseBoilerplateConfig([]byte(CONFIG_ONE_VARIABLE_OPTIONS_FOR_NON_ENUM)) assert.NotNil(t, err) - assert.True(t, errors.IsError(err, OptionsCanOnlyBeUsedWithEnum{VariableName: "foo", VariableType: String}), "Expected a OptionsCanOnlyBeUsedWithEnum error but got %v", err) + assert.True(t, errors.IsError(err, variables.OptionsCanOnlyBeUsedWithEnum{Context: "foo", Type: variables.String}), "Expected a OptionsCanOnlyBeUsedWithEnum error but got %v", err) } // YAML is whitespace sensitive, so we need to be careful that we don't introduce unnecessary indentation @@ -176,12 +242,13 @@ func TestParseBoilerplateConfigMultipleVariables(t *testing.T) { actual, err := ParseBoilerplateConfig([]byte(CONFIG_MULTIPLE_VARIABLES)) expected := &BoilerplateConfig{ - Variables: []Variable{ - {Name: "foo", Type: String}, - {Name: "bar", Description: "example description", Type: String}, - {Name: "baz", Description: "example description", Type: Int, Default: 3}, - {Name: "dep1.baz", Description: "another example description", Type: Bool, Default: true}, + Variables: []variables.Variable{ + variables.NewStringVariable("foo"), + variables.NewStringVariable("bar").WithDescription("example description"), + variables.NewIntVariable("baz").WithDescription("example description").WithDefault(3), + variables.NewBoolVariable("dep1.baz").WithDescription("another example description").WithDefault(true), }, + Dependencies: []variables.Dependency{}, } assert.Nil(t, err) @@ -238,16 +305,17 @@ func TestParseBoilerplateConfigAllTypes(t *testing.T) { actual, err := ParseBoilerplateConfig([]byte(CONFIG_ALL_TYPES)) expected := &BoilerplateConfig{ - Variables: []Variable{ - {Name: "var1", Type: String, Default: "foo"}, - {Name: "var2", Type: String, Default: "foo"}, - {Name: "var3", Type: Int, Default: 5}, - {Name: "var4", Type: Float, Default: 5.5}, - {Name: "var5", Type: Bool, Default: true}, - {Name: "var6", Type: List, Default: []string{"foo", "bar", "baz"}}, - {Name: "var7", Type: Map, Default: map[string]string{"key1": "value1", "key2": "value2", "key3": "value3"}}, - {Name: "var8", Type: Enum, Default: "bar", Options: []string{"foo", "bar", "baz"}}, + Variables: []variables.Variable{ + variables.NewStringVariable("var1").WithDefault("foo"), + variables.NewStringVariable("var2").WithDefault("foo"), + variables.NewIntVariable("var3").WithDefault(5), + variables.NewFloatVariable("var4").WithDefault(5.5), + variables.NewBoolVariable("var5").WithDefault(true), + variables.NewListVariable("var6").WithDefault([]string{"foo", "bar", "baz"}), + variables.NewMapVariable("var7").WithDefault(map[string]string{"key1": "value1", "key2": "value2", "key3": "value3"}), + variables.NewEnumVariable("var8", []string{"foo", "bar", "baz"}).WithDefault("bar"), }, + Dependencies: []variables.Dependency{}, } assert.Nil(t, err) @@ -267,8 +335,9 @@ func TestParseBoilerplateConfigOneDependency(t *testing.T) { actual, err := ParseBoilerplateConfig([]byte(CONFIG_ONE_DEPENDENCY)) expected := &BoilerplateConfig{ - Dependencies: []Dependency{ - {Name: "dep1", TemplateFolder: "/template/folder1", OutputFolder: "/output/folder1", DontInheritVariables: false}, + Variables: []variables.Variable{}, + Dependencies: []variables.Dependency{ + {Name: "dep1", TemplateFolder: "/template/folder1", OutputFolder: "/output/folder1", DontInheritVariables: false, Variables: []variables.Variable{}}, }, } @@ -302,20 +371,22 @@ func TestParseBoilerplateConfigMultipleDependencies(t *testing.T) { actual, err := ParseBoilerplateConfig([]byte(CONFIG_MULTIPLE_DEPENDENCIES)) expected := &BoilerplateConfig{ - Dependencies: []Dependency{ + Variables: []variables.Variable{}, + Dependencies: []variables.Dependency{ { Name: "dep1", TemplateFolder: "/template/folder1", OutputFolder: "/output/folder1", DontInheritVariables: false, + Variables: []variables.Variable{}, }, { Name: "dep2", TemplateFolder: "/template/folder2", OutputFolder: "/output/folder2", DontInheritVariables: true, - Variables: []Variable{ - {Name: "var1", Description: "Enter var1", Default: "foo", Type: String}, + Variables: []variables.Variable{ + variables.NewStringVariable("var1").WithDescription("Enter var1").WithDefault("foo"), }, }, { @@ -323,6 +394,7 @@ func TestParseBoilerplateConfigMultipleDependencies(t *testing.T) { TemplateFolder: "/template/folder3", OutputFolder: "/output/folder3", DontInheritVariables: false, + Variables: []variables.Variable{}, }, }, } @@ -344,7 +416,7 @@ func TestParseBoilerplateConfigDependencyMissingName(t *testing.T) { _, err := ParseBoilerplateConfig([]byte(CONFIG_DEPENDENCY_MISSING_NAME)) assert.NotNil(t, err) - assert.True(t, errors.IsError(err, MissingNameForDependency(0)), "Expected a MissingNameForDependency error but got %s", reflect.TypeOf(err)) + assert.True(t, errors.IsError(err, variables.RequiredFieldMissing("name")), "Expected a RequiredFieldMissing error but got %s", reflect.TypeOf(errors.Unwrap(err))) } // YAML is whitespace sensitive, so we need to be careful that we don't introduce unnecessary indentation @@ -360,7 +432,7 @@ func TestParseBoilerplateConfigDependencyMissingTemplateFolder(t *testing.T) { _, err := ParseBoilerplateConfig([]byte(CONFIG_DEPENDENCY_MISSING_TEMPLATE_FOLDER)) assert.NotNil(t, err) - assert.True(t, errors.IsError(err, MissingTemplateFolderForDependency("dep1")), "Expected a MissingTemplateFolderForDependency error but got %s", reflect.TypeOf(err)) + assert.True(t, errors.IsError(err, variables.RequiredFieldMissing("template-folder")), "Expected a RequiredFieldMissing error but got %s", reflect.TypeOf(errors.Unwrap(err))) } // YAML is whitespace sensitive, so we need to be careful that we don't introduce unnecessary indentation @@ -380,7 +452,7 @@ func TestParseBoilerplateConfigDependencyMissingVariableName(t *testing.T) { _, err := ParseBoilerplateConfig([]byte(CONFIG_DEPENDENCY_MISSING_VARIABLE_NAME)) assert.NotNil(t, err) - assert.True(t, errors.IsError(err, RequiredFieldMissing("name")), "Expected a RequiredFieldMissing error but got %s", reflect.TypeOf(err)) + assert.True(t, errors.IsError(err, variables.RequiredFieldMissing("name")), "Expected a RequiredFieldMissing error but got %s", reflect.TypeOf(errors.Unwrap(err))) } // YAML is whitespace sensitive, so we need to be careful that we don't introduce unnecessary indentation @@ -400,7 +472,7 @@ func TestParseBoilerplateConfigDependencyMissingOutputFolder(t *testing.T) { _, err := ParseBoilerplateConfig([]byte(CONFIG_DEPENDENCY_MISSING_OUTPUT_FOLDER)) assert.NotNil(t, err) - assert.True(t, errors.IsError(err, MissingOutputFolderForDependency("dep2")), "Expected a MissingOutputFolderForDependency error but got %s", reflect.TypeOf(err)) + assert.True(t, errors.IsError(err, variables.RequiredFieldMissing("output-folder")), "Expected a RequiredFieldMissing error but got %s", reflect.TypeOf(errors.Unwrap(err))) } // YAML is whitespace sensitive, so we need to be careful that we don't introduce unnecessary indentation @@ -425,7 +497,7 @@ func TestParseBoilerplateConfigDependencyDuplicateNames(t *testing.T) { _, err := ParseBoilerplateConfig([]byte(CONFIG_DEPENDENCY_DUPLICATE_NAMES)) assert.NotNil(t, err) - assert.True(t, errors.IsError(err, DuplicateDependencyName("dep1")), "Expected a DuplicateDependencyName error but got %s", reflect.TypeOf(err)) + assert.True(t, errors.IsError(err, variables.DuplicateDependencyName("dep1")), "Expected a DuplicateDependencyName error but got %s", reflect.TypeOf(errors.Unwrap(err))) } func TestLoadBoilerplateConfigFullConfig(t *testing.T) { @@ -433,16 +505,16 @@ func TestLoadBoilerplateConfigFullConfig(t *testing.T) { actual, err := LoadBoilerplateConfig(&BoilerplateOptions{TemplateFolder: "../test-fixtures/config-test/full-config"}) expected := &BoilerplateConfig{ - Variables: []Variable{ - {Name: "foo", Type: String}, - {Name: "bar", Type: String, Description: "example description"}, - {Name: "baz", Type: String, Description: "example description", Default: "default"}, + Variables: []variables.Variable{ + variables.NewStringVariable("foo"), + variables.NewStringVariable("bar").WithDescription("example description"), + variables.NewStringVariable("baz").WithDescription("example description").WithDefault("default"), }, - Dependencies: []Dependency{ - {Name: "dep1", TemplateFolder: "/template/folder1", OutputFolder: "/output/folder1", DontInheritVariables: false}, - {Name: "dep2", TemplateFolder: "/template/folder2", OutputFolder: "/output/folder2", DontInheritVariables: true, Variables: []Variable{ - {Name: "baz", Type: String, Description: "example description", Default: "other-default"}, - {Name: "abc", Type: String, Description: "example description", Default: "default"}, + Dependencies: []variables.Dependency{ + {Name: "dep1", TemplateFolder: "/template/folder1", OutputFolder: "/output/folder1", DontInheritVariables: false, Variables: []variables.Variable{}}, + {Name: "dep2", TemplateFolder: "/template/folder2", OutputFolder: "/output/folder2", DontInheritVariables: true, Variables: []variables.Variable{ + variables.NewStringVariable("baz").WithDescription("example description").WithDefault("other-default"), + variables.NewStringVariable("abc").WithDescription("example description").WithDefault("default"), }}, }, } diff --git a/config/dependencies.go b/config/dependencies.go deleted file mode 100644 index 33f929b9..00000000 --- a/config/dependencies.go +++ /dev/null @@ -1,75 +0,0 @@ -package config - -import ( - "strings" - "github.com/gruntwork-io/boilerplate/errors" - "github.com/gruntwork-io/boilerplate/util" - "fmt" -) - -// A single boilerplate template that this boilerplate.yml depends on being executed first -type Dependency struct { - Name string - TemplateFolder string `yaml:"template-folder"` - OutputFolder string `yaml:"output-folder"` - DontInheritVariables bool `yaml:"dont-inherit-variables"` - Variables []Variable -} - -// Given a unique variable name, return a tuple that contains the dependency name (if any) and the variable name. -// Variable and dependency names are split by a dot, so for "foo.bar", this will return ("foo", "bar"). For just "foo", -// it will return ("", "foo"). -func SplitIntoDependencyNameAndVariableName(uniqueVariableName string) (string, string) { - parts := strings.SplitAfterN(uniqueVariableName, ".", 2) - if len(parts) == 2 { - // The split method leaves the character you split on at the end of the string, so we have to trim it - return strings.TrimSuffix(parts[0], "."), parts[1] - } else { - return "", parts[0] - } -} - -// Validate that the list of dependencies has reasonable contents and return an error if there is a problem -func validateDependencies(dependencies []Dependency) error { - dependencyNames := []string{} - for i, dependency := range dependencies { - if dependency.Name == "" { - return errors.WithStackTrace(MissingNameForDependency(i)) - } - if util.ListContains(dependency.Name, dependencyNames) { - return errors.WithStackTrace(DuplicateDependencyName(dependency.Name)) - } - dependencyNames = append(dependencyNames, dependency.Name) - - if dependency.TemplateFolder == "" { - return errors.WithStackTrace(MissingTemplateFolderForDependency(dependency.Name)) - } - if dependency.OutputFolder == "" { - return errors.WithStackTrace(MissingOutputFolderForDependency(dependency.Name)) - } - } - - return nil -} - -// Custom error types - -type MissingNameForDependency int -func (index MissingNameForDependency) Error() string { - return fmt.Sprintf("The name parameter was missing for dependency number %d", int(index) + 1) -} - -type DuplicateDependencyName string -func (name DuplicateDependencyName) Error() string { - return fmt.Sprintf("Found a duplicate dependency name: %s. All dependency names must be unique!", string(name)) -} - -type MissingTemplateFolderForDependency string -func (name MissingTemplateFolderForDependency) Error() string { - return fmt.Sprintf("The %s parameter was missing for dependency %s", OPT_TEMPLATE_FOLDER, string(name)) -} - -type MissingOutputFolderForDependency string -func (name MissingOutputFolderForDependency) Error() string { - return fmt.Sprintf("The %s parameter was missing for dependency %s", OPT_OUTPUT_FOLDER, string(name)) -} \ No newline at end of file diff --git a/config/get_variables.go b/config/get_variables.go new file mode 100644 index 00000000..81847358 --- /dev/null +++ b/config/get_variables.go @@ -0,0 +1,119 @@ +package config + +import ( + "fmt" + "github.com/gruntwork-io/boilerplate/util" + "github.com/gruntwork-io/boilerplate/errors" + "github.com/gruntwork-io/boilerplate/variables" +) + +// Get a value for each of the variables specified in boilerplateConfig, other than those already in existingVariables. +// The value for a variable can come from the user (if the non-interactive option isn't set), the default value in the +// config, or a command line option. +func GetVariables(options *BoilerplateOptions, boilerplateConfig *BoilerplateConfig) (map[string]interface{}, error) { + vars := map[string]interface{}{} + for key, value := range options.Vars { + vars[key] = value + } + + variablesInConfig := getAllVariablesInConfig(boilerplateConfig) + + for _, variable := range variablesInConfig { + var value interface{} + var err error + + value, alreadyExists := vars[variable.Name()] + if !alreadyExists { + value, err = getVariable(variable, options) + if err != nil { + return vars, err + } + } + + unmarshalled, err := variables.UnmarshalValueForVariable(value, variable) + if err != nil { + return vars, err + } + + vars[variable.Name()] = unmarshalled + } + + return vars, nil +} + +// Get all the variables defined in the given config and its dependencies +func getAllVariablesInConfig(boilerplateConfig *BoilerplateConfig) []variables.Variable { + allVariables := []variables.Variable{} + + allVariables = append(allVariables, boilerplateConfig.Variables...) + + for _, dependency := range boilerplateConfig.Dependencies { + allVariables = append(allVariables, dependency.GetNamespacedVariables()...) + } + + return allVariables +} + +// Get a value for the given variable. The value can come from the user (if the non-interactive option isn't set), the +// default value in the config, or a command line option. +func getVariable(variable variables.Variable, options *BoilerplateOptions) (interface{}, error) { + valueFromVars, valueSpecifiedInVars := getVariableFromVars(variable, options) + + if valueSpecifiedInVars { + util.Logger.Printf("Using value specified via command line options for variable '%s': %s", variable.FullName(), valueFromVars) + return valueFromVars, nil + } else if options.NonInteractive && variable.Default() != nil { + util.Logger.Printf("Using default value for variable '%s': %v", variable.FullName(), variable.Default()) + return variable.Default(), nil + } else if options.NonInteractive { + return nil, errors.WithStackTrace(MissingVariableWithNonInteractiveMode(variable.FullName())) + } else { + return getVariableFromUser(variable, options) + } +} + +// Return the value of the given variable from vars passed in as command line options +func getVariableFromVars(variable variables.Variable, options *BoilerplateOptions) (interface{}, bool) { + for name, value := range options.Vars { + if name == variable.Name() { + return value, true + } + } + + return nil, false +} + +// Get the value for the given variable by prompting the user +func getVariableFromUser(variable variables.Variable, options *BoilerplateOptions) (interface{}, error) { + util.BRIGHT_GREEN.Printf("\n%s\n", variable.FullName()) + if variable.Description() != "" { + fmt.Printf(" %s\n", variable.Description()) + } + if variable.Default() != nil { + fmt.Printf(" (default: %s)\n", variable.Default()) + } + fmt.Println() + + // TODO: show type info + // TODO: show user examples of how to enter values of different types + + value, err := util.PromptUserForInput(" Enter a value") + if err != nil { + return "", err + } + + if value == "" { + // TODO: what if the user wanted an empty string instead of the default? + util.Logger.Printf("Using default value for variable '%s': %v", variable.FullName(), variable.Default()) + return variable.Default(), nil + } + + return variables.ParseYamlString(value) +} + +// Custom error types + +type MissingVariableWithNonInteractiveMode string +func (variableName MissingVariableWithNonInteractiveMode) Error() string { + return fmt.Sprintf("Variable '%s' does not have a default, no value was specified at the command line using the --%s option, and the --%s flag is set, so cannot prompt user for a value.", string(variableName), OPT_VAR, OPT_NON_INTERACTIVE) +} \ No newline at end of file diff --git a/config/variables_test.go b/config/get_variables_test.go similarity index 84% rename from config/variables_test.go rename to config/get_variables_test.go index fd9cea07..debe559d 100644 --- a/config/variables_test.go +++ b/config/get_variables_test.go @@ -5,12 +5,13 @@ import ( "github.com/stretchr/testify/assert" "reflect" "github.com/gruntwork-io/boilerplate/errors" + "github.com/gruntwork-io/boilerplate/variables" ) func TestGetVariableFromVarsEmptyVars(t *testing.T) { t.Parallel() - variable := Variable{Name: "foo"} + variable := variables.NewStringVariable("foo") options := &BoilerplateOptions{} _, containsValue := getVariableFromVars(variable, options) @@ -20,7 +21,7 @@ func TestGetVariableFromVarsEmptyVars(t *testing.T) { func TestGetVariableFromVarsNoMatch(t *testing.T) { t.Parallel() - variable := Variable{Name: "foo"} + variable := variables.NewStringVariable("foo") options := &BoilerplateOptions{ Vars: map[string]interface{}{ "key1": "value1", @@ -36,7 +37,7 @@ func TestGetVariableFromVarsNoMatch(t *testing.T) { func TestGetVariableFromVarsMatch(t *testing.T) { t.Parallel() - variable := Variable{Name: "foo"} + variable := variables.NewStringVariable("foo") options := &BoilerplateOptions{ Vars: map[string]interface{}{ "key1": "value1", @@ -55,7 +56,7 @@ func TestGetVariableFromVarsMatch(t *testing.T) { func TestGetVariableFromVarsForDependencyNoMatch(t *testing.T) { t.Parallel() - variable := Variable{Name: "bar.foo"} + variable := variables.NewStringVariable("bar.foo") options := &BoilerplateOptions{ Vars: map[string]interface{}{ "key1": "value1", @@ -71,7 +72,7 @@ func TestGetVariableFromVarsForDependencyNoMatch(t *testing.T) { func TestGetVariableFromVarsForDependencyMatch(t *testing.T) { t.Parallel() - variable := Variable{Name: "bar.foo"} + variable := variables.NewStringVariable("bar.foo") options := &BoilerplateOptions{ Vars: map[string]interface{}{ "key1": "value1", @@ -90,7 +91,7 @@ func TestGetVariableFromVarsForDependencyMatch(t *testing.T) { func TestGetVariableNoMatchNonInteractive(t *testing.T) { t.Parallel() - variable := Variable{Name: "foo"} + variable := variables.NewStringVariable("foo") options := &BoilerplateOptions{NonInteractive: true} _, err := getVariable(variable, options) @@ -102,7 +103,7 @@ func TestGetVariableNoMatchNonInteractive(t *testing.T) { func TestGetVariableInVarsNonInteractive(t *testing.T) { t.Parallel() - variable := Variable{Name: "foo"} + variable := variables.NewStringVariable("foo") options := &BoilerplateOptions{ NonInteractive: true, Vars: map[string]interface{}{ @@ -122,7 +123,7 @@ func TestGetVariableInVarsNonInteractive(t *testing.T) { func TestGetVariableDefaultNonInteractive(t *testing.T) { t.Parallel() - variable := Variable{Name: "foo", Default: "bar"} + variable := variables.NewStringVariable("foo").WithDefault("bar") options := &BoilerplateOptions{ NonInteractive: true, Vars: map[string]interface{}{ @@ -157,8 +158,8 @@ func TestGetVariablesNoMatchNonInteractive(t *testing.T) { options := &BoilerplateOptions{NonInteractive: true} boilerplateConfig := &BoilerplateConfig{ - Variables: []Variable{ - {Name: "foo", Type: String}, + Variables: []variables.Variable{ + variables.NewStringVariable("foo"), }, } @@ -179,8 +180,8 @@ func TestGetVariablesMatchFromVars(t *testing.T) { } boilerplateConfig := &BoilerplateConfig{ - Variables: []Variable{ - {Name: "foo", Type: String}, + Variables: []variables.Variable{ + variables.NewStringVariable("foo"), }, } @@ -205,10 +206,10 @@ func TestGetVariablesMatchFromVarsAndDefaults(t *testing.T) { } boilerplateConfig := &BoilerplateConfig{ - Variables: []Variable{ - {Name: "key1", Type: String}, - {Name: "key2", Type: String}, - {Name: "key3", Type: String, Default: "value3"}, + Variables: []variables.Variable{ + variables.NewStringVariable("key1"), + variables.NewStringVariable("key2"), + variables.NewStringVariable("key3").WithDefault("value3"), }, } diff --git a/config/options.go b/config/options.go index 63c30535..b940be92 100644 --- a/config/options.go +++ b/config/options.go @@ -5,6 +5,7 @@ import ( "github.com/gruntwork-io/boilerplate/util" "fmt" "github.com/urfave/cli" + "github.com/gruntwork-io/boilerplate/variables" ) // The command-line options for the boilerplate app @@ -36,7 +37,7 @@ func (options *BoilerplateOptions) Validate() error { // Parse the command line options provided by the user func ParseOptions(cliContext *cli.Context) (*BoilerplateOptions, error) { - vars, err := parseVars(cliContext.StringSlice(OPT_VAR), cliContext.StringSlice(OPT_VAR_FILE)) + vars, err := variables.ParseVars(cliContext.StringSlice(OPT_VAR), cliContext.StringSlice(OPT_VAR_FILE)) if err != nil { return nil, err } diff --git a/config/variables.go b/config/variables.go deleted file mode 100644 index 77cbcbd4..00000000 --- a/config/variables.go +++ /dev/null @@ -1,161 +0,0 @@ -package config - -import ( - "fmt" - "github.com/gruntwork-io/boilerplate/util" - "github.com/gruntwork-io/boilerplate/errors" -) - -// A single variable defined in a boilerplate.yml config file -type Variable struct { - Name string - Description string - Type BoilerplateType - Default interface{} - Options []string -} - -// Return the full name of this variable, which includes its name and the dependency it is for (if any) in a -// human-readable format -func (variable Variable) FullName() string { - dependencyName, variableName := SplitIntoDependencyNameAndVariableName(variable.Name) - if dependencyName == "" { - return variableName - } else { - return fmt.Sprintf("%s (for dependency %s)", variableName, dependencyName) - } -} - -// Return a human-readable string representation of this variable -func (variable Variable) String() string { - return fmt.Sprintf("Variable {Name: '%s', Description: '%s', Type: '%s', Default: '%v', Options: '%v'}", variable.Name, variable.Description, variable.Type, variable.Default, variable.Options) -} - -// Implement the go-yaml unmarshal interface for Variable. We can't let go-yaml handle this itself because we need to: -// -// 1. Set Defaults for missing fields (e.g. Type) -// 2. Validate the type corresponds to the Default value -// 3. Validate Options are only specified for the Enum Type -func (variable *Variable) UnmarshalYAML(unmarshal func(interface{}) error) error { - var fields map[string]interface{} - if err := unmarshal(&fields); err != nil { - return err - } - - if unmarshalled, err := UnmarshalVariable(fields); err != nil { - return err - } else { - *variable = *unmarshalled - return nil - } -} - -// Get a value for each of the variables specified in boilerplateConfig, other than those already in existingVariables. -// The value for a variable can come from the user (if the non-interactive option isn't set), the default value in the -// config, or a command line option. -func GetVariables(options *BoilerplateOptions, boilerplateConfig *BoilerplateConfig) (map[string]interface{}, error) { - variables := map[string]interface{}{} - for key, value := range options.Vars { - variables[key] = value - } - - variablesInConfig := getAllVariablesInConfig(boilerplateConfig) - - for _, variable := range variablesInConfig { - var value interface{} - var err error - - value, alreadyExists := variables[variable.Name] - if !alreadyExists { - value, err = getVariable(variable, options) - if err != nil { - return variables, err - } - } - - variables[variable.Name], err = UnmarshalVariableValue(value, variable) - if err != nil { - return variables, err - } - } - - return variables, nil -} - -// Get all the variables defined in the given config and its dependencies -func getAllVariablesInConfig(boilerplateConfig *BoilerplateConfig) []Variable { - allVariables := []Variable{} - - allVariables = append(allVariables, boilerplateConfig.Variables...) - - for _, dependency := range boilerplateConfig.Dependencies { - for _, variable := range dependency.Variables { - variableName := fmt.Sprintf("%s.%s", dependency.Name, variable.Name) - allVariables = append(allVariables, Variable{Name: variableName, Description: variable.Description, Type: variable.Type, Default: variable.Default, Options: variable.Options}) - } - } - - return allVariables -} - -// Get a value for the given variable. The value can come from the user (if the non-interactive option isn't set), the -// default value in the config, or a command line option. -func getVariable(variable Variable, options *BoilerplateOptions) (interface{}, error) { - valueFromVars, valueSpecifiedInVars := getVariableFromVars(variable, options) - - if valueSpecifiedInVars { - util.Logger.Printf("Using value specified via command line options for variable '%s': %s", variable.FullName(), valueFromVars) - return valueFromVars, nil - } else if options.NonInteractive && variable.Default != nil { - // TODO: how to disambiguate between a default not being specified and a default set to an empty string? - util.Logger.Printf("Using default value for variable '%s': %v", variable.FullName(), variable.Default) - return variable.Default, nil - } else if options.NonInteractive { - return nil, errors.WithStackTrace(MissingVariableWithNonInteractiveMode(variable.FullName())) - } else { - return getVariableFromUser(variable, options) - } -} - -// Return the value of the given variable from vars passed in as command line options -func getVariableFromVars(variable Variable, options *BoilerplateOptions) (interface{}, bool) { - for name, value := range options.Vars { - if name == variable.Name { - return value, true - } - } - - return nil, false -} - -// Get the value for the given variable by prompting the user -func getVariableFromUser(variable Variable, options *BoilerplateOptions) (interface{}, error) { - util.BRIGHT_GREEN.Printf("\n%s\n", variable.FullName()) - if variable.Description != "" { - fmt.Printf(" %s\n", variable.Description) - } - if variable.Default != "" { - fmt.Printf(" (default: %s)\n", variable.Default) - } - fmt.Println() - - value, err := util.PromptUserForInput(" Enter a value") - if err != nil { - return "", err - } - - if value == "" { - // TODO: what if the user wanted an empty string instead of the default? - util.Logger.Printf("Using default value for variable '%s': %v", variable.FullName(), variable.Default) - return variable.Default, nil - } - - return parseYamlString(value) -} - -// Custom error types - -type MissingVariableWithNonInteractiveMode string -func (variableName MissingVariableWithNonInteractiveMode) Error() string { - return fmt.Sprintf("Variable '%s' does not have a default, no value was specified at the command line using the --%s option, and the --%s flag is set, so cannot prompt user for a value.", string(variableName), OPT_VAR, OPT_NON_INTERACTIVE) -} \ No newline at end of file diff --git a/templates/template_processor.go b/templates/template_processor.go index 971e81e9..5030a1b7 100644 --- a/templates/template_processor.go +++ b/templates/template_processor.go @@ -11,6 +11,7 @@ import ( "io/ioutil" "path" "fmt" + "github.com/gruntwork-io/boilerplate/variables" ) // Process the boilerplate template specified in the given options and use the existing variables. This function will @@ -43,7 +44,7 @@ func ProcessTemplate(options *config.BoilerplateOptions) error { } // Execute the boilerplate templates in the given list of dependencies -func processDependencies(dependencies []config.Dependency, options *config.BoilerplateOptions, variables map[string]interface{}) error { +func processDependencies(dependencies []variables.Dependency, options *config.BoilerplateOptions, variables map[string]interface{}) error { for _, dependency := range dependencies { err := processDependency(dependency, options, variables) if err != nil { @@ -55,7 +56,7 @@ func processDependencies(dependencies []config.Dependency, options *config.Boile } // Execute the boilerplate template in the given dependency -func processDependency(dependency config.Dependency, options *config.BoilerplateOptions, variables map[string]interface{}) error { +func processDependency(dependency variables.Dependency, options *config.BoilerplateOptions, variables map[string]interface{}) error { shouldProcess, err := shouldProcessDependency(dependency, options) if err != nil { return err @@ -74,7 +75,7 @@ func processDependency(dependency config.Dependency, options *config.Boilerplate // Clone the given options for use when rendering the given dependency. The dependency will get the same options as // the original passed in, except for the template folder, output folder, and command-line vars. -func cloneOptionsForDependency(dependency config.Dependency, originalOptions *config.BoilerplateOptions, variables map[string]interface{}) *config.BoilerplateOptions { +func cloneOptionsForDependency(dependency variables.Dependency, originalOptions *config.BoilerplateOptions, variables map[string]interface{}) *config.BoilerplateOptions { templateFolder := pathRelativeToTemplate(originalOptions.TemplateFolder, dependency.TemplateFolder) outputFolder := pathRelativeToTemplate(originalOptions.OutputFolder, dependency.OutputFolder) @@ -91,7 +92,7 @@ func cloneOptionsForDependency(dependency config.Dependency, originalOptions *co // Clone the given variables for use when rendering the given dependency. The dependency will get the same variables // as the originals passed in, filtered to variable names that do not include a dependency or explicitly are for the // given dependency. If dependency.DontInheritVariables is set to true, an empty map is returned. -func cloneVariablesForDependency(dependency config.Dependency, originalVariables map[string]interface{}) map[string]interface{} { +func cloneVariablesForDependency(dependency variables.Dependency, originalVariables map[string]interface{}) map[string]interface{} { newVariables := map[string]interface{}{} if dependency.DontInheritVariables { @@ -99,7 +100,7 @@ func cloneVariablesForDependency(dependency config.Dependency, originalVariables } for variableName, variableValue := range originalVariables { - dependencyName, variableOriginalName := config.SplitIntoDependencyNameAndVariableName(variableName) + dependencyName, variableOriginalName := variables.SplitIntoDependencyNameAndVariableName(variableName) if dependencyName == dependency.Name { newVariables[variableOriginalName] = variableValue } else if _, alreadyExists := newVariables[variableName]; !alreadyExists { @@ -112,7 +113,7 @@ func cloneVariablesForDependency(dependency config.Dependency, originalVariables // Prompt the user to verify if the given dependency should be executed and return true if they confirm. If // options.NonInteractive is set to true, this function always returns true. -func shouldProcessDependency(dependency config.Dependency, options *config.BoilerplateOptions) (bool, error) { +func shouldProcessDependency(dependency variables.Dependency, options *config.BoilerplateOptions) (bool, error) { if options.NonInteractive { return true, nil } diff --git a/templates/template_processor_test.go b/templates/template_processor_test.go index d83db3e0..28735466 100644 --- a/templates/template_processor_test.go +++ b/templates/template_processor_test.go @@ -5,6 +5,7 @@ import ( "github.com/stretchr/testify/assert" "os" "github.com/gruntwork-io/boilerplate/config" + "github.com/gruntwork-io/boilerplate/variables" ) func TestOutPath(t *testing.T) { @@ -131,19 +132,19 @@ func TestCloneOptionsForDependency(t *testing.T) { t.Parallel() testCases := []struct { - dependency config.Dependency + dependency variables.Dependency options config.BoilerplateOptions variables map[string]interface{} expectedOptions config.BoilerplateOptions }{ { - config.Dependency{Name: "dep1", TemplateFolder: "../dep1", OutputFolder: "../out1"}, + variables.Dependency{Name: "dep1", TemplateFolder: "../dep1", OutputFolder: "../out1"}, config.BoilerplateOptions{TemplateFolder: "/template/path/", OutputFolder: "/output/path/", NonInteractive: true, Vars: map[string]interface{}{}, OnMissingKey: config.ExitWithError}, map[string]interface{}{}, config.BoilerplateOptions{TemplateFolder: "/template/dep1", OutputFolder: "/output/out1", NonInteractive: true, Vars: map[string]interface{}{}, OnMissingKey: config.ExitWithError}, }, { - config.Dependency{Name: "dep1", TemplateFolder: "../dep1", OutputFolder: "../out1"}, + variables.Dependency{Name: "dep1", TemplateFolder: "../dep1", OutputFolder: "../out1"}, config.BoilerplateOptions{TemplateFolder: "/template/path/", OutputFolder: "/output/path/", NonInteractive: false, Vars: map[string]interface{}{"foo": "bar"}, OnMissingKey: config.Invalid}, map[string]interface{}{"baz": "blah"}, config.BoilerplateOptions{TemplateFolder: "/template/dep1", OutputFolder: "/output/out1", NonInteractive: false, Vars: map[string]interface{}{"baz": "blah"}, OnMissingKey: config.Invalid}, @@ -160,32 +161,32 @@ func TestCloneVariablesForDependency(t *testing.T) { t.Parallel() testCases := []struct { - dependency config.Dependency + dependency variables.Dependency variables map[string]interface{} expectedVariables map[string]interface{} }{ { - config.Dependency{Name: "dep1", TemplateFolder: "../dep1", OutputFolder: "../out1"}, + variables.Dependency{Name: "dep1", TemplateFolder: "../dep1", OutputFolder: "../out1"}, map[string]interface{}{}, map[string]interface{}{}, }, { - config.Dependency{Name: "dep1", TemplateFolder: "../dep1", OutputFolder: "../out1"}, + variables.Dependency{Name: "dep1", TemplateFolder: "../dep1", OutputFolder: "../out1"}, map[string]interface{}{"foo": "bar", "baz": "blah"}, map[string]interface{}{"foo": "bar", "baz": "blah"}, }, { - config.Dependency{Name: "dep1", TemplateFolder: "../dep1", OutputFolder: "../out1"}, + variables.Dependency{Name: "dep1", TemplateFolder: "../dep1", OutputFolder: "../out1"}, map[string]interface{}{"foo": "bar", "baz": "blah", "dep1.abc": "should-modify-name", "dep2.def": "should-copy-unmodified"}, map[string]interface{}{"foo": "bar", "baz": "blah", "abc": "should-modify-name", "dep2.def": "should-copy-unmodified"}, }, { - config.Dependency{Name: "dep1", TemplateFolder: "../dep1", OutputFolder: "../out1"}, + variables.Dependency{Name: "dep1", TemplateFolder: "../dep1", OutputFolder: "../out1"}, map[string]interface{}{"foo": "bar", "baz": "blah", "dep1.abc": "should-modify-name", "dep2.def": "should-copy-unmodified", "abc": "should-be-overwritten-by-dep1.abc"}, map[string]interface{}{"foo": "bar", "baz": "blah", "abc": "should-modify-name", "dep2.def": "should-copy-unmodified"}, }, { - config.Dependency{Name: "dep1", TemplateFolder: "../dep1", OutputFolder: "../out1", DontInheritVariables: true}, + variables.Dependency{Name: "dep1", TemplateFolder: "../dep1", OutputFolder: "../out1", DontInheritVariables: true}, map[string]interface{}{"foo": "bar", "baz": "blah", "dep1.abc": "should-modify-name", "dep2.def": "should-copy-unmodified"}, map[string]interface{}{}, }, diff --git a/util/collections.go b/util/collections.go index 32daa3ea..a4e9ee12 100644 --- a/util/collections.go +++ b/util/collections.go @@ -48,7 +48,19 @@ func ToStringMap(genericMap map[interface{}]interface{}) map[string]string { return stringMap } +// Convert a generic map to a map from string to interface +func ToStringToGenericMap(genericMap map[interface{}]interface{}) map[string]interface{} { + stringToGenericMap := map[string]interface{}{} + + for key, value := range genericMap { + stringToGenericMap[ToString(key)] = value + } + + return stringToGenericMap +} + // Convert a single value to its string representation func ToString(value interface{}) string { return fmt.Sprintf("%v", value) -} \ No newline at end of file +} + diff --git a/variables/dependencies.go b/variables/dependencies.go new file mode 100644 index 00000000..e03afe6b --- /dev/null +++ b/variables/dependencies.go @@ -0,0 +1,109 @@ +package variables + +import ( + "strings" + "github.com/gruntwork-io/boilerplate/errors" + "github.com/gruntwork-io/boilerplate/util" + "fmt" +) + +// A single boilerplate template that this boilerplate.yml depends on being executed first +type Dependency struct { + Name string + TemplateFolder string + OutputFolder string + DontInheritVariables bool + Variables []Variable +} + +func (dependency Dependency) GetNamespacedVariables() []Variable { + variables := []Variable{} + + for _, variable := range dependency.Variables { + variableNameForDependency := fmt.Sprintf("%s.%s", dependency.Name, variable.Name()) + variables = append(variables, variable.WithName(variableNameForDependency)) + } + + return variables +} + +// Given a unique variable name, return a tuple that contains the dependency name (if any) and the variable name. +// Variable and dependency names are split by a dot, so for "foo.bar", this will return ("foo", "bar"). For just "foo", +// it will return ("", "foo"). +func SplitIntoDependencyNameAndVariableName(uniqueVariableName string) (string, string) { + parts := strings.SplitAfterN(uniqueVariableName, ".", 2) + if len(parts) == 2 { + // The split method leaves the character you split on at the end of the string, so we have to trim it + return strings.TrimSuffix(parts[0], "."), parts[1] + } else { + return "", parts[0] + } +} + +func UnmarshalDependencies(fields map[string]interface{}, fieldName string) ([]Dependency, error) { + unmarshalledDependencies := []Dependency{} + dependencyNames := []string{} + + listOfFields, err := unmarshalListOfFields(fields, fieldName) + if err != nil { + return unmarshalledDependencies, err + } + + for _, fields := range listOfFields { + dependency, err := UnmarshalDependency(fields) + if err != nil { + return unmarshalledDependencies, err + } + + if util.ListContains(dependency.Name, dependencyNames) { + return unmarshalledDependencies, errors.WithStackTrace(DuplicateDependencyName(dependency.Name)) + } + dependencyNames = append(dependencyNames, dependency.Name) + + unmarshalledDependencies = append(unmarshalledDependencies, *dependency) + } + + return unmarshalledDependencies, nil +} + +func UnmarshalDependency(fields map[string]interface{}) (*Dependency, error) { + name, err := unmarshalStringField(fields, "name", true, "") + if err != nil { + return nil, err + } + + templateFolder, err := unmarshalStringField(fields, "template-folder", true, *name) + if err != nil { + return nil, err + } + + outputFolder, err := unmarshalStringField(fields, "output-folder", true, *name) + if err != nil { + return nil, err + } + + dontInheritVariables, err := unmarshalBooleanField(fields, "dont-inherit-variables", false, *name) + if err != nil { + return nil, err + } + + variables, err := UnmarshalVariables(fields, "variables") + if err != nil { + return nil, err + } + + return &Dependency{ + Name: *name, + TemplateFolder: *templateFolder, + OutputFolder: *outputFolder, + DontInheritVariables: dontInheritVariables, + Variables: variables, + }, nil +} + +// Custom error types + +type DuplicateDependencyName string +func (name DuplicateDependencyName) Error() string { + return fmt.Sprintf("Found a duplicate dependency name: %s. All dependency names must be unique!", string(name)) +} diff --git a/config/dependencies_test.go b/variables/dependencies_test.go similarity index 97% rename from config/dependencies_test.go rename to variables/dependencies_test.go index 6d531afc..4da2c111 100644 --- a/config/dependencies_test.go +++ b/variables/dependencies_test.go @@ -1,8 +1,8 @@ -package config +package variables import ( - "testing" "github.com/stretchr/testify/assert" + "testing" ) func TestSplitIntoDependencyNameAndVariableName(t *testing.T) { @@ -26,3 +26,4 @@ func TestSplitIntoDependencyNameAndVariableName(t *testing.T) { assert.Equal(t, testCase.expectedOriginalVariableName, actualOriginalVariableName, "Variable name: %s", testCase.variableName) } } + diff --git a/config/types.go b/variables/types.go similarity index 98% rename from config/types.go rename to variables/types.go index fea32df7..3f1bcc4f 100644 --- a/config/types.go +++ b/variables/types.go @@ -1,4 +1,4 @@ -package config +package variables import ( "github.com/gruntwork-io/boilerplate/errors" @@ -44,3 +44,4 @@ func (err InvalidBoilerplateType) Error() string { return fmt.Sprintf("Invalid InvalidBoilerplateType '%s'. Value must be one of: %s", string(err), ALL_BOILERPLATE_TYPES) } + diff --git a/variables/variables.go b/variables/variables.go new file mode 100644 index 00000000..7342e6ba --- /dev/null +++ b/variables/variables.go @@ -0,0 +1,243 @@ +package variables + +import ( + "fmt" + "github.com/gruntwork-io/boilerplate/util" +) + +// An interface for a variable defined in a boilerplate.yml config file +type Variable interface { + // The name of the variable + Name() string + + // The full name of this variable, which includes its name and the dependency it is for (if any) in a + // human-readable format + FullName() string + + // The description of the variable, if any + Description() string + + // The type of the variable + Type() BoilerplateType + + // The default value for teh variable, if any + Default() interface{} + + // The values this variable can take. Applies only if Type() is Enum. + Options() []string + + // Return a copy of this variable but with the name set to the given name + WithName(string) Variable + + // Return a copy of this variable but with the description set to the given description + WithDescription(string) Variable + + // Return a copy of this variable but with the default set to the given value + WithDefault(interface{}) Variable +} + +// A private implementation of the Variable interface that forces all users to use our public constructors +type defaultVariable struct { + name string + description string + defaultValue interface{} + variableType BoilerplateType + options []string +} + +func NewStringVariable(name string) Variable { + return defaultVariable{ + name: name, + variableType: String, + } +} + +func NewIntVariable(name string) Variable { + return defaultVariable{ + name: name, + variableType: Int, + } +} + +func NewFloatVariable(name string) Variable { + return defaultVariable{ + name: name, + variableType: Float, + } +} + +func NewBoolVariable(name string) Variable { + return defaultVariable{ + name: name, + variableType: Bool, + } +} + +func NewListVariable(name string, ) Variable { + return defaultVariable{ + name: name, + variableType: List, + } +} + +func NewMapVariable(name string) Variable { + return defaultVariable{ + name: name, + variableType: Map, + } +} + +func NewEnumVariable(name string, options []string) Variable { + return defaultVariable{ + name: name, + variableType: Enum, + options: options, + } +} + +func (variable defaultVariable) Name() string { + return variable.name +} + +func (variable defaultVariable) FullName() string { + dependencyName, variableName := SplitIntoDependencyNameAndVariableName(variable.Name()) + if dependencyName == "" { + return variableName + } else { + return fmt.Sprintf("%s (for dependency %s)", variableName, dependencyName) + } +} + +func (variable defaultVariable) Description() string { + return variable.description +} + +func (variable defaultVariable) Type() BoilerplateType { + return variable.variableType +} + +func (variable defaultVariable) Default() interface{} { + return variable.defaultValue +} + +func (variable defaultVariable) Options() []string { + return variable.options +} + +func (variable defaultVariable) WithName(name string) Variable { + variable.name = name + return variable +} + +func (variable defaultVariable) WithDescription(description string) Variable { + variable.description = description + return variable +} + +func (variable defaultVariable) WithDefault(value interface{}) Variable { + variable.defaultValue = value + return variable +} + +func UnmarshalValueForVariable(value interface{}, variable Variable) (interface{}, error) { + if value == nil { + return nil, nil + } + + switch variable.Type() { + case String: + if asString, isString := value.(string); isString { + return asString, nil + } + case Int: + if asInt, isInt := value.(int); isInt { + return asInt, nil + } + case Float: + if asFloat, isFloat := value.(float64); isFloat { + return asFloat, nil + } + case Bool: + if asBool, isBool := value.(bool); isBool { + return asBool, nil + } + case List: + if asList, isList := value.([]interface{}); isList { + return util.ToStringList(asList), nil + } + case Map: + if asMap, isMap := value.(map[interface{}]interface{}); isMap { + return util.ToStringMap(asMap), nil + } + case Enum: + if asString, isString := value.(string); isString { + for _, option := range variable.Options() { + if asString == option { + return asString, nil + } + } + } + } + + return nil, InvalidVariableValue{Variable: variable, Value: value} +} + +func UnmarshalVariables(fields map[string]interface{}, fieldName string) ([]Variable, error) { + unmarshalledVariables := []Variable{} + + listOfFields, err := unmarshalListOfFields(fields, fieldName) + if err != nil { + return unmarshalledVariables, err + } + + for _, fields := range listOfFields { + variable, err := UnmarshalVariable(fields) + if err != nil { + return unmarshalledVariables, err + } + unmarshalledVariables = append(unmarshalledVariables, variable) + } + + return unmarshalledVariables, nil +} + +// Given a map where the keys are the fields of a boilerplate Variable, this method crates a Variable struct with those +// fields filled in with proper types. This method also validates all the fields and returns an error if any problems +// are found. +func UnmarshalVariable(fields map[string]interface{}) (Variable, error) { + variable := defaultVariable{} + + name, err := unmarshalStringField(fields, "name", true, "") + if err != nil { + return nil, err + } + variable.name = *name + + variableType, err := unmarshalTypeField(fields, "type", *name) + if err != nil { + return nil, err + } + variable.variableType = variableType + + description, err := unmarshalStringField(fields, "description", false, *name) + if err != nil { + return nil, err + } + if description != nil { + variable.description = *description + } + + options, err := unmarshalOptionsField(fields, "options", *name, variableType) + if err != nil { + return nil, err + } + variable.options = options + + defaultValue, err := UnmarshalValueForVariable(fields["default"], variable) + if err != nil { + return nil, err + } + variable.defaultValue = defaultValue + + return variable, nil +} diff --git a/variables/variables_test.go b/variables/variables_test.go new file mode 100644 index 00000000..0899c852 --- /dev/null +++ b/variables/variables_test.go @@ -0,0 +1 @@ +package variables diff --git a/config/variables_from_yaml.go b/variables/yaml_helpers.go similarity index 57% rename from config/variables_from_yaml.go rename to variables/yaml_helpers.go index feb20cc4..a73b971c 100644 --- a/config/variables_from_yaml.go +++ b/variables/yaml_helpers.go @@ -1,4 +1,4 @@ -package config +package variables import ( "github.com/gruntwork-io/boilerplate/errors" @@ -10,120 +10,65 @@ import ( "fmt" ) -// Given a map where the keys are the fields of a boilerplate Variable, this method crates a Variable struct with those -// fields filled in with proper types. This method also validates all the fields and returns an error if any problems -// are found. -func UnmarshalVariable(fields map[string]interface{}) (*Variable, error) { - variable := Variable{} - var err error +func unmarshalListOfFields(fields map[string]interface{}, fieldName string) ([]map[string]interface{}, error) { + listOfFields := []map[string]interface{}{} - variable.Name, err = unmarshalStringField(fields, "name", true, "") - if err != nil { - return nil, err - } - - variable.Description, err = unmarshalStringField(fields, "description", false, variable.Name) - if err != nil { - return nil, err - } - - variable.Type, err = unmarshalTypeField(fields, "type", variable.Name) - if err != nil { - return nil, err + fieldAsYaml, containsField := fields[fieldName] + if !containsField || fieldAsYaml == nil { + return listOfFields, nil } - variable.Options, err = unmarshalOptionsField(fields, "options", variable.Name, variable.Type) - if err != nil { - return nil, err + asYamlList, isYamlList := fieldAsYaml.([]interface{}) + if !isYamlList { + return listOfFields, errors.WithStackTrace(InvalidTypeForField{FieldName: fieldName, ExpectedType: "[]interface{}", ActualType: reflect.TypeOf(fieldAsYaml)}) } - variable.Default, err = UnmarshalVariableValue(fields["default"], variable) - if err != nil { - return nil, err - } - - return &variable, nil -} - -// Convert the given value to the proper type for the given variable, or return an error if the type doesn't match. -// For example, if this variable is of type List, then the returned value will be a list of strings. -func UnmarshalVariableValue(value interface{}, variable Variable) (interface{}, error) { - if value == nil { - return nil, nil - } - - switch variable.Type { - case String: - if asString, isString := value.(string); isString { - return asString, nil - } - case Int: - if asInt, isInt := value.(int); isInt { - return asInt, nil - } - case Float: - if asFloat, isFloat := value.(float64); isFloat { - return asFloat, nil - } - case Bool: - if asBool, isBool := value.(bool); isBool { - return asBool, nil - } - case List: - if asList, isList := value.([]interface{}); isList { - return util.ToStringList(asList), nil - } - case Map: - if asMap, isMap := value.(map[interface{}]interface{}); isMap { - return util.ToStringMap(asMap), nil - } - case Enum: - if asString, isString := value.(string); isString { - for _, option := range variable.Options { - if asString == option { - return asString, nil - } - } + for _, asYaml := range asYamlList { + asYamlMap, isYamlMap := asYaml.(map[interface{}]interface{}) + if !isYamlMap { + return listOfFields, errors.WithStackTrace(InvalidTypeForField{FieldName: fieldName, ExpectedType: "map[string]interface{}", ActualType: reflect.TypeOf(asYaml)}) } + + listOfFields = append(listOfFields, util.ToStringToGenericMap(asYamlMap)) } - return nil, InvalidVariableValue{Variable: variable, Value: value} + return listOfFields, nil } // Extract the options field from the given map of fields using the given field name and convert those options to a // list of strings. -func unmarshalOptionsField(fields map[string]interface{}, fieldName string, variableName string, variableType BoilerplateType) ([]string, error) { +func unmarshalOptionsField(fields map[string]interface{}, fieldName string, context string, variableType BoilerplateType) ([]string, error) { options, hasOptions := fields[fieldName] if !hasOptions { if variableType == Enum { - return nil, errors.WithStackTrace(VariableMissingOptions(variableName)) + return nil, errors.WithStackTrace(OptionsMissing(context)) } else { return nil, nil } } if variableType != Enum { - return nil, errors.WithStackTrace(OptionsCanOnlyBeUsedWithEnum{VariableName: variableName, VariableType: variableType}) + return nil, errors.WithStackTrace(OptionsCanOnlyBeUsedWithEnum{Context: context, Type: variableType}) } optionsAsList, isList := options.([]interface{}) if !isList { - return nil, errors.WithStackTrace(InvalidTypeForField{FieldName: fieldName, ExpectedType: "List", ActualType: reflect.TypeOf(options).String(), VariableName: variableName}) + return nil, errors.WithStackTrace(InvalidTypeForField{FieldName: fieldName, ExpectedType: "List", ActualType: reflect.TypeOf(options), Context: context}) } return util.ToStringList(optionsAsList), nil } // Extract the type field from the map of fields using the given field name and convert the type to a BoilerplateType. -func unmarshalTypeField(fields map[string]interface{}, fieldName string, variableName string) (BoilerplateType, error) { - variableTypeAsString, err := unmarshalStringField(fields, fieldName, false, variableName) +func unmarshalTypeField(fields map[string]interface{}, fieldName string, context string) (BoilerplateType, error) { + variableTypeAsString, err := unmarshalStringField(fields, fieldName, false, context) if err != nil { return BOILERPLATE_TYPE_DEFAULT, err } - if variableTypeAsString != "" { - variableType, err := ParseBoilerplateType(variableTypeAsString) + if variableTypeAsString != nil { + variableType, err := ParseBoilerplateType(*variableTypeAsString) if err != nil { return BOILERPLATE_TYPE_DEFAULT, err } @@ -135,20 +80,39 @@ func unmarshalTypeField(fields map[string]interface{}, fieldName string, variabl // Extract a string field from the map of fields using the given field name and convert it to a string. If no such // field is in the map of fields but requiredField is set to true, return an error. -func unmarshalStringField(fields map[string]interface{}, fieldName string, requiredField bool, variableName string) (string, error) { +func unmarshalStringField(fields map[string]interface{}, fieldName string, requiredField bool, context string) (*string, error) { value, hasValue := fields[fieldName] if !hasValue { if requiredField { - return "", errors.WithStackTrace(RequiredFieldMissing(fieldName)) + return nil, errors.WithStackTrace(RequiredFieldMissing(fieldName)) } else { - return "", nil + return nil, nil } } if valueAsString, isString := value.(string); isString { - return valueAsString, nil + return &valueAsString, nil } else { - return "", errors.WithStackTrace(InvalidTypeForField{FieldName: fieldName, ExpectedType: "string", ActualType: reflect.TypeOf(value).String(), VariableName: variableName}) + return nil, errors.WithStackTrace(InvalidTypeForField{FieldName: fieldName, ExpectedType: "string", ActualType: reflect.TypeOf(value), Context: context}) + } +} + +// Extract a boolean field from the map of fields using the given field name and convert it to a bool. If no such +// field is in the map of fields but requiredField is set to true, return an error. +func unmarshalBooleanField(fields map[string]interface{}, fieldName string, requiredField bool, context string) (bool, error) { + value, hasValue := fields[fieldName] + if !hasValue { + if requiredField { + return false, errors.WithStackTrace(RequiredFieldMissing(fieldName)) + } else { + return false, nil + } + } + + if valueAsBool, isBool:= value.(bool); isBool { + return valueAsBool, nil + } else { + return false, errors.WithStackTrace(InvalidTypeForField{FieldName: fieldName, ExpectedType: "bool", ActualType: reflect.TypeOf(value), Context: context}) } } @@ -169,7 +133,7 @@ func parseVariablesFromKeyValuePairs(varsList []string) (map[string]interface{}, return vars, errors.WithStackTrace(VariableNameCannotBeEmpty(variable)) } - parsedValue, err := parseYamlString(value) + parsedValue, err := ParseYamlString(value) if err != nil { return vars, err } @@ -181,7 +145,7 @@ func parseVariablesFromKeyValuePairs(varsList []string) (map[string]interface{}, } // Parse a YAML string into a Go type -func parseYamlString(str string) (interface{}, error) { +func ParseYamlString(str string) (interface{}, error) { var parsedValue interface{} err := yaml.Unmarshal([]byte(str), &parsedValue) @@ -235,7 +199,7 @@ func parseVariablesFromVarFileContents(varFileContents []byte)(map[string]interf // Parse variables passed in via command line options, either as a list of NAME=VALUE variable pairs in varsList, or a // list of paths to YAML files that define NAME: VALUE pairs. Return a map of the NAME: VALUE pairs. Along the way, // each VALUE is parsed as YAML. -func parseVars(varsList []string, varFileList[]string) (map[string]interface{}, error) { +func ParseVars(varsList []string, varFileList[]string) (map[string]interface{}, error) { variables := map[string]interface{}{} varsFromVarsList, err := parseVariablesFromKeyValuePairs(varsList) @@ -253,9 +217,9 @@ func parseVars(varsList []string, varFileList[]string) (map[string]interface{}, // Custom error types -type VariableMissingOptions string -func (err VariableMissingOptions) Error() string { - return fmt.Sprintf("Variable %s has type %s but does not specify any options. You must specify at least one option.", string(err), Enum) +type OptionsMissing string +func (err OptionsMissing) Error() string { + return fmt.Sprintf("%s has type %s but does not specify any options. You must specify at least one option.", string(err), Enum) } type InvalidVariableValue struct { @@ -263,32 +227,29 @@ type InvalidVariableValue struct { Variable Variable } func (err InvalidVariableValue) Error() string { - message := fmt.Sprintf("Value '%v' is not a valid value for variable '%s' with type '%s'.", err.Value, err.Variable.Name, err.Variable.Type.String()) - if err.Variable.Type == Enum { - message = fmt.Sprintf("%s. Value must be one of: %s.", message, err.Variable.Options) + message := fmt.Sprintf("Value '%v' is not a valid value for variable '%s' with type '%s'.", err.Value, err.Variable.Name(), err.Variable.Type().String()) + if err.Variable.Type() == Enum { + message = fmt.Sprintf("%s. Value must be one of: %s.", message, err.Variable.Options()) } return message } type OptionsCanOnlyBeUsedWithEnum struct { - VariableName string - VariableType BoilerplateType + Context string + Type BoilerplateType } func (err OptionsCanOnlyBeUsedWithEnum) Error() string { - return fmt.Sprintf("Variable %s has type %s and tries to specify options. Options may only be specified for variables of type %s.", err.VariableName, err.VariableType.String(), Enum) + return fmt.Sprintf("%s has type %s and tries to specify options. Options may only be specified for the %s type.", err.Context, err.Type.String(), Enum) } type InvalidTypeForField struct { - FieldName string - VariableName string + FieldName string ExpectedType string - ActualType string + ActualType reflect.Type + Context string } func (err InvalidTypeForField) Error() string { - message := fmt.Sprintf("%s must have type %s but got %s", err.FieldName, err.ExpectedType, err.ActualType) - if err.VariableName != "" { - message = fmt.Sprintf("%s for variable %s", message, err.VariableName) - } + message := fmt.Sprintf("Field %s in %s must have type %s but got %s", err.FieldName, err.Context, err.ExpectedType, err.ActualType) return message } @@ -304,5 +265,10 @@ func (varSyntax InvalidVarSyntax) Error() string { type RequiredFieldMissing string func (err RequiredFieldMissing) Error() string { - return fmt.Sprintf("Variable is missing required field %s", string(err)) + return fmt.Sprintf("Required field %s is missing", string(err)) +} + +type UnrecognizedBoilerplateType BoilerplateType +func (err UnrecognizedBoilerplateType) Error() string { + return fmt.Sprintf("Unrecognized type: %s", BoilerplateType(err).String()) } \ No newline at end of file diff --git a/config/variables_from_yaml_test.go b/variables/yaml_helpers_test.go similarity index 99% rename from config/variables_from_yaml_test.go rename to variables/yaml_helpers_test.go index 3e83dcd9..4f02f93e 100644 --- a/config/variables_from_yaml_test.go +++ b/variables/yaml_helpers_test.go @@ -1,4 +1,4 @@ -package config +package variables import ( "testing" @@ -76,4 +76,4 @@ func TestParseVariablesFromKeyValuePairs(t *testing.T) { assert.True(t, errors.IsError(err, testCase.expectedError), "Expected an error of type '%s' with value '%s' but got an error of type '%s' with value '%s'", reflect.TypeOf(testCase.expectedError), testCase.expectedError.Error(), reflect.TypeOf(err), err.Error()) } } -} \ No newline at end of file +} From a59bb55e4833fbc806a48d8ca47c11b74d7d4ac7 Mon Sep 17 00:00:00 2001 From: Yevgeniy Brikman Date: Tue, 20 Sep 2016 00:20:26 +0100 Subject: [PATCH 5/7] Improve comments --- config/config.go | 14 ++++----- config/get_variables.go | 4 +-- variables/dependencies.go | 30 +++++++++++++++---- variables/types.go | 14 +++++++++ variables/variables.go | 44 +++++++++++++++++++++------ variables/variables_test.go | 1 - variables/yaml_helpers.go | 60 +++++++++++++++++++++++++++++-------- 7 files changed, 130 insertions(+), 37 deletions(-) delete mode 100644 variables/variables_test.go diff --git a/config/config.go b/config/config.go index 94d40ad8..0623d8f4 100644 --- a/config/config.go +++ b/config/config.go @@ -26,24 +26,24 @@ type BoilerplateConfig struct { Dependencies []variables.Dependency } -// Implement the go-yaml unmarshal interface for BoilerplateConfig. We can't let go-yaml handle this itself because we -// need to: +// Implement the go-yaml unmarshal interface for BoilerplateConfig. We can't let go-yaml handle this itself because: // -// 1. Set Defaults for missing fields (e.g. Type) -// 2. Validate the type corresponds to the Default value -// 3. Validate Options are only specified for the Enum Type +// 1. Variable is an interface +// 2. We need to provide Defaults for optional fields, such as "type" +// 3. We want to validate the variable as part of the unmarshalling process so we never have invalid Variable or +// Dependency classes floating around func (config *BoilerplateConfig) UnmarshalYAML(unmarshal func(interface{}) error) error { var fields map[string]interface{} if err := unmarshal(&fields); err != nil { return err } - vars, err := variables.UnmarshalVariables(fields, "variables") + vars, err := variables.UnmarshalVariablesFromBoilerplateConfigYaml(fields) if err != nil { return err } - deps, err := variables.UnmarshalDependencies(fields, "dependencies") + deps, err := variables.UnmarshalDependenciesFromBoilerplateConfigYaml(fields) if err != nil { return err } diff --git a/config/get_variables.go b/config/get_variables.go index 81847358..6e6fc34d 100644 --- a/config/get_variables.go +++ b/config/get_variables.go @@ -92,11 +92,9 @@ func getVariableFromUser(variable variables.Variable, options *BoilerplateOption if variable.Default() != nil { fmt.Printf(" (default: %s)\n", variable.Default()) } + fmt.Printf(" (type: %s, example: %s)\n", variable.Type(), variable.Type().Example()) fmt.Println() - // TODO: show type info - // TODO: show user examples of how to enter values of different types - value, err := util.PromptUserForInput(" Enter a value") if err != nil { return "", err diff --git a/variables/dependencies.go b/variables/dependencies.go index e03afe6b..41d0e550 100644 --- a/variables/dependencies.go +++ b/variables/dependencies.go @@ -16,6 +16,7 @@ type Dependency struct { Variables []Variable } +// Get all the variables in this dependency, namespacing each variable with the name of this dependency func (dependency Dependency) GetNamespacedVariables() []Variable { variables := []Variable{} @@ -40,17 +41,29 @@ func SplitIntoDependencyNameAndVariableName(uniqueVariableName string) (string, } } -func UnmarshalDependencies(fields map[string]interface{}, fieldName string) ([]Dependency, error) { +// Given a map of key:value pairs read from a Boilerplate YAML config file of the format: +// +// dependencies: +// - name: +// template-folder: +// output-folder: +// +// - name: +// template-folder: +// output-folder: +// +// This method takes the data above and unmarshals it into a list of Dependency objects +func UnmarshalDependenciesFromBoilerplateConfigYaml(fields map[string]interface{}) ([]Dependency, error) { unmarshalledDependencies := []Dependency{} dependencyNames := []string{} - listOfFields, err := unmarshalListOfFields(fields, fieldName) + listOfFields, err := unmarshalListOfFields(fields, "dependencies") if err != nil { return unmarshalledDependencies, err } for _, fields := range listOfFields { - dependency, err := UnmarshalDependency(fields) + dependency, err := UnmarshalDependencyFromBoilerplateConfigYaml(fields) if err != nil { return unmarshalledDependencies, err } @@ -66,7 +79,14 @@ func UnmarshalDependencies(fields map[string]interface{}, fieldName string) ([]D return unmarshalledDependencies, nil } -func UnmarshalDependency(fields map[string]interface{}) (*Dependency, error) { +// Given a map of key:value pairs read from a Boilerplate YAML config file of the format: +// +// name: +// template-folder: +// output-folder: +// +// This method takes the data above and unmarshals it into a Dependency object +func UnmarshalDependencyFromBoilerplateConfigYaml(fields map[string]interface{}) (*Dependency, error) { name, err := unmarshalStringField(fields, "name", true, "") if err != nil { return nil, err @@ -87,7 +107,7 @@ func UnmarshalDependency(fields map[string]interface{}) (*Dependency, error) { return nil, err } - variables, err := UnmarshalVariables(fields, "variables") + variables, err := UnmarshalVariablesFromBoilerplateConfigYaml(fields) if err != nil { return nil, err } diff --git a/variables/types.go b/variables/types.go index 3f1bcc4f..d0fe9918 100644 --- a/variables/types.go +++ b/variables/types.go @@ -37,6 +37,20 @@ func (boilerplateType BoilerplateType) String() string { return string(boilerplateType) } +// Return an example value for this type. This is useful for showing a user the proper syntax to use for that type. +func (boilerplateType BoilerplateType) Example() string { + switch boilerplateType { + case String: return "foo" + case Int: return "42" + case Float: return "3.1415926" + case Bool: return "true" + case List: return "[foo, bar, baz]" + case Map: return "{foo: bar, baz: blah}" + case Enum: return "foo" + default: return "" + } +} + // Custom error types type InvalidBoilerplateType string diff --git a/variables/variables.go b/variables/variables.go index 7342e6ba..82b37d84 100644 --- a/variables/variables.go +++ b/variables/variables.go @@ -45,6 +45,7 @@ type defaultVariable struct { options []string } +// Create a new variable that holds a string func NewStringVariable(name string) Variable { return defaultVariable{ name: name, @@ -52,6 +53,7 @@ func NewStringVariable(name string) Variable { } } +// Create a new variable that holds an int func NewIntVariable(name string) Variable { return defaultVariable{ name: name, @@ -59,6 +61,7 @@ func NewIntVariable(name string) Variable { } } +// Create a new variable that holds a float func NewFloatVariable(name string) Variable { return defaultVariable{ name: name, @@ -66,6 +69,7 @@ func NewFloatVariable(name string) Variable { } } +// Create a new variable that holds a bool func NewBoolVariable(name string) Variable { return defaultVariable{ name: name, @@ -73,6 +77,7 @@ func NewBoolVariable(name string) Variable { } } +// Create a new variable that holds a list of strings func NewListVariable(name string, ) Variable { return defaultVariable{ name: name, @@ -80,6 +85,7 @@ func NewListVariable(name string, ) Variable { } } +// Create a new variable that holds a map of string to string func NewMapVariable(name string) Variable { return defaultVariable{ name: name, @@ -87,6 +93,7 @@ func NewMapVariable(name string) Variable { } } +// Create a new variable that holds an enum with the given possible values func NewEnumVariable(name string, options []string) Variable { return defaultVariable{ name: name, @@ -139,6 +146,8 @@ func (variable defaultVariable) WithDefault(value interface{}) Variable { return variable } +// Convert the given value to a type that can be used with the given variable. If the type of the value cannot be used +// with the type of the variable, return an error. func UnmarshalValueForVariable(value interface{}, variable Variable) (interface{}, error) { if value == nil { return nil, nil @@ -182,16 +191,28 @@ func UnmarshalValueForVariable(value interface{}, variable Variable) (interface{ return nil, InvalidVariableValue{Variable: variable, Value: value} } -func UnmarshalVariables(fields map[string]interface{}, fieldName string) ([]Variable, error) { +// Given a map of key:value pairs read from a Boilerplate YAML config file of the format: +// +// variables: +// - name: +// description: +// type: +// +// - name: +// description: +// default: +// +// This method takes the data above and unmarshals it into a list of Variable objects +func UnmarshalVariablesFromBoilerplateConfigYaml(fields map[string]interface{}) ([]Variable, error) { unmarshalledVariables := []Variable{} - listOfFields, err := unmarshalListOfFields(fields, fieldName) + listOfFields, err := unmarshalListOfFields(fields, "variables") if err != nil { return unmarshalledVariables, err } for _, fields := range listOfFields { - variable, err := UnmarshalVariable(fields) + variable, err := UnmarshalVariableFromBoilerplateConfigYaml(fields) if err != nil { return unmarshalledVariables, err } @@ -201,10 +222,15 @@ func UnmarshalVariables(fields map[string]interface{}, fieldName string) ([]Vari return unmarshalledVariables, nil } -// Given a map where the keys are the fields of a boilerplate Variable, this method crates a Variable struct with those -// fields filled in with proper types. This method also validates all the fields and returns an error if any problems -// are found. -func UnmarshalVariable(fields map[string]interface{}) (Variable, error) { +// Given a map of key:value pairs read from a Boilerplate YAML config file of the format: +// +// name: +// description: +// type: +// default: +// +// This method takes the data above and unmarshals it into a Variable object +func UnmarshalVariableFromBoilerplateConfigYaml(fields map[string]interface{}) (Variable, error) { variable := defaultVariable{} name, err := unmarshalStringField(fields, "name", true, "") @@ -213,7 +239,7 @@ func UnmarshalVariable(fields map[string]interface{}) (Variable, error) { } variable.name = *name - variableType, err := unmarshalTypeField(fields, "type", *name) + variableType, err := unmarshalTypeField(fields, *name) if err != nil { return nil, err } @@ -227,7 +253,7 @@ func UnmarshalVariable(fields map[string]interface{}) (Variable, error) { variable.description = *description } - options, err := unmarshalOptionsField(fields, "options", *name, variableType) + options, err := unmarshalOptionsField(fields, *name, variableType) if err != nil { return nil, err } diff --git a/variables/variables_test.go b/variables/variables_test.go deleted file mode 100644 index 0899c852..00000000 --- a/variables/variables_test.go +++ /dev/null @@ -1 +0,0 @@ -package variables diff --git a/variables/yaml_helpers.go b/variables/yaml_helpers.go index a73b971c..c141e787 100644 --- a/variables/yaml_helpers.go +++ b/variables/yaml_helpers.go @@ -10,6 +10,19 @@ import ( "fmt" ) +// Given a map of key:value pairs read from a Boilerplate YAML config file of the format: +// +// fieldName: +// - key1: value1 +// key2: value2 +// key3: value3 +// +// - key1: value1 +// key2: value2 +// key3: value3 +// +// This method takes looks up the given fieldName in the map and unmarshals the data inside of it it into a list of +// maps, where each map contains the set of key:value pairs func unmarshalListOfFields(fields map[string]interface{}, fieldName string) ([]map[string]interface{}, error) { listOfFields := []map[string]interface{}{} @@ -35,10 +48,19 @@ func unmarshalListOfFields(fields map[string]interface{}, fieldName string) ([]m return listOfFields, nil } -// Extract the options field from the given map of fields using the given field name and convert those options to a -// list of strings. -func unmarshalOptionsField(fields map[string]interface{}, fieldName string, context string, variableType BoilerplateType) ([]string, error) { - options, hasOptions := fields[fieldName] +// Given a map of key:value pairs read from a Boilerplate YAML config file of the format: +// +// options: +// - foo +// - bar +// - baz +// +// This method takes looks up the options object in the map and unmarshals the data inside of it it into a list of +// strings. This is meant to be used to parse the options field of an Enum variable. If the given variableType is not +// an Enum and options have been specified, or this variable is an Enum and options have not been specified, this +// method will return an error. +func unmarshalOptionsField(fields map[string]interface{}, context string, variableType BoilerplateType) ([]string, error) { + options, hasOptions := fields["options"] if !hasOptions { if variableType == Enum { @@ -54,15 +76,21 @@ func unmarshalOptionsField(fields map[string]interface{}, fieldName string, cont optionsAsList, isList := options.([]interface{}) if !isList { - return nil, errors.WithStackTrace(InvalidTypeForField{FieldName: fieldName, ExpectedType: "List", ActualType: reflect.TypeOf(options), Context: context}) + return nil, errors.WithStackTrace(InvalidTypeForField{FieldName: "options", ExpectedType: "List", ActualType: reflect.TypeOf(options), Context: context}) } return util.ToStringList(optionsAsList), nil } -// Extract the type field from the map of fields using the given field name and convert the type to a BoilerplateType. -func unmarshalTypeField(fields map[string]interface{}, fieldName string, context string) (BoilerplateType, error) { - variableTypeAsString, err := unmarshalStringField(fields, fieldName, false, context) +// Given a map of key:value pairs read from a Boilerplate YAML config file of the format: +// +// type: +// +// This method takes looks up the options key in the map and unmarshals the data inside of it it into a +// BoilerplateType. If no type is specified, this method returns the default type (String). If an unrecognized type is +// specified, this method returns an error. +func unmarshalTypeField(fields map[string]interface{}, context string) (BoilerplateType, error) { + variableTypeAsString, err := unmarshalStringField(fields, "type", false, context) if err != nil { return BOILERPLATE_TYPE_DEFAULT, err } @@ -78,8 +106,12 @@ func unmarshalTypeField(fields map[string]interface{}, fieldName string, context return BOILERPLATE_TYPE_DEFAULT, nil } -// Extract a string field from the map of fields using the given field name and convert it to a string. If no such -// field is in the map of fields but requiredField is set to true, return an error. +// Given a map of key:value pairs read from a Boilerplate YAML config file of the format: +// +// fieldName: +// +// This method takes looks up the given fieldName in the map and unmarshals the data inside of it into a string. If +// requiredField is true and fieldName was not in the map, this method will return an error. func unmarshalStringField(fields map[string]interface{}, fieldName string, requiredField bool, context string) (*string, error) { value, hasValue := fields[fieldName] if !hasValue { @@ -97,8 +129,12 @@ func unmarshalStringField(fields map[string]interface{}, fieldName string, requi } } -// Extract a boolean field from the map of fields using the given field name and convert it to a bool. If no such -// field is in the map of fields but requiredField is set to true, return an error. +// Given a map of key:value pairs read from a Boilerplate YAML config file of the format: +// +// fieldName: +// +// This method takes looks up the given fieldName in the map and unmarshals the data inside of it into a bool. If +// requiredField is true and fieldName was not in the map, this method will return an error. func unmarshalBooleanField(fields map[string]interface{}, fieldName string, requiredField bool, context string) (bool, error) { value, hasValue := fields[fieldName] if !hasValue { From 361329f86bfdc7ec8cd969a652c444faf8efc654 Mon Sep 17 00:00:00 2001 From: Yevgeniy Brikman Date: Tue, 20 Sep 2016 01:05:37 +0100 Subject: [PATCH 6/7] Add example that uses types. Add keys helper. --- _docs/README.md | 2 ++ examples/java-project/boilerplate.yml | 20 +++++++++++++++++++ .../example/Example.java | 17 ++++++++++++++++ templates/template_helpers.go | 16 +++++++++++++++ templates/template_processor_test.go | 1 + .../com/acme/example/Example.java | 12 +++++++++++ .../examples-var-files/java-project/vars.yml | 11 ++++++++++ 7 files changed, 79 insertions(+) create mode 100644 examples/java-project/boilerplate.yml create mode 100644 examples/java-project/com/{{.CompanyName | dasherize | downcase}}/example/Example.java create mode 100644 test-fixtures/examples-expected-output/java-project/com/acme/example/Example.java create mode 100644 test-fixtures/examples-var-files/java-project/vars.yml diff --git a/_docs/README.md b/_docs/README.md index 7e5cc23a..db2d99b7 100644 --- a/_docs/README.md +++ b/_docs/README.md @@ -343,6 +343,8 @@ including conditionals, loops, and functions. Boilerplate also includes several * `mod INT INT`: Return the remainder of dividing the two numbers. * `slice START END INCREMENT`: Generate a slice from START to END, incrementing by INCREMENT. This provides a simple way to do a for-loop over a range of numbers. +* `keys MAP`: Return a slice that contains all the keys in the given MAP. Use the built-in Go template helper `.index` + to look up these keys in the map. ## Alternative project generators diff --git a/examples/java-project/boilerplate.yml b/examples/java-project/boilerplate.yml new file mode 100644 index 00000000..61e120dc --- /dev/null +++ b/examples/java-project/boilerplate.yml @@ -0,0 +1,20 @@ +variables: + - name: CompanyName + description: Enter the name of the company + type: string + + - name: ExampleIntConstant + description: Enter the value for an integer constant in the Java file + type: int + + - name: IncludeEnum + description: Should we create an example Enum in the Java file? + type: bool + + - name: EnumNames + description: Enter the names of the Enums in the Java file + type: list + + - name: ExampleMap + description: Enter some example values to store in a HashMap + type: map \ No newline at end of file diff --git a/examples/java-project/com/{{.CompanyName | dasherize | downcase}}/example/Example.java b/examples/java-project/com/{{.CompanyName | dasherize | downcase}}/example/Example.java new file mode 100644 index 00000000..d379f57c --- /dev/null +++ b/examples/java-project/com/{{.CompanyName | dasherize | downcase}}/example/Example.java @@ -0,0 +1,17 @@ +package com.{{.CompanyName | dasherize | downcase}}.example + +import com.google.common.collect.ImmutableMap; + +public class Example { + public static final int EXAMPLE_INT_CONSTANT = {{.ExampleIntConstant}} + public static final Map<> EXAMPLE_MAP = ImmutableMap.of({{range $index, $key := .ExampleMap | keys}}{{if gt $index 0}}, {{end}}"{{$key}}", "{{index $.ExampleMap $key}}"{{end}}); + +{{- if .IncludeEnum}} + + public enum ExampleEnum { + {{range $index, $enumName := .EnumNames -}} + {{if gt $index 0}}, {{end}}{{$enumName}} + {{- end }} + } +{{- end }} +} \ No newline at end of file diff --git a/templates/template_helpers.go b/templates/template_helpers.go index c8092252..34e3ff1a 100644 --- a/templates/template_helpers.go +++ b/templates/template_helpers.go @@ -15,6 +15,7 @@ import ( "strconv" "unicode" "github.com/gruntwork-io/boilerplate/util" + "sort" ) var SNIPPET_MARKER_REGEX = regexp.MustCompile("boilerplate-snippet:\\s*(.+?)(?:\\s|$)") @@ -61,6 +62,7 @@ func CreateTemplateHelpers(templatePath string) template.FuncMap { "divide": wrapFloatFloatToFloatFunction(func(arg1 float64, arg2 float64) float64 { return arg1 / arg2 }), "mod": wrapIntIntToIntFunction(func(arg1 int, arg2 int) int { return arg1 % arg2 }), "slice": slice, + "keys": keys, } } @@ -400,6 +402,20 @@ func slice(start interface{}, end interface{}, increment interface{}) ([]int, er return out, nil } +// Return the keys in the given map. This method always returns the keys in sorted order to provide a stable iteration +// order. +func keys(m map[string]string) []string { + out := []string{} + + for key, _ := range m { + out = append(out, key) + } + + sort.Strings(out) + + return out +} + // Custom errors type SnippetNotFound string diff --git a/templates/template_processor_test.go b/templates/template_processor_test.go index 28735466..4d5337a9 100644 --- a/templates/template_processor_test.go +++ b/templates/template_processor_test.go @@ -113,6 +113,7 @@ func TestRenderTemplate(t *testing.T) { {"Divide test: {{ divide .Foo .Bar | printf \"%1.5f\" }}", map[string]interface{}{"Foo": "5", "Bar": "3"}, config.ExitWithError, "", "Divide test: 1.66667"}, {"Mod test: {{ mod .Foo .Bar }}", map[string]interface{}{"Foo": "5", "Bar": "3"}, config.ExitWithError, "", "Mod test: 2"}, {"Slice test: {{ slice 0 5 1 }}", map[string]interface{}{}, config.ExitWithError, "", "Slice test: [0 1 2 3 4]"}, + {"Keys test: {{ keys .Map }}", map[string]interface{}{"Map": map[string]string{"key1": "value1", "key2": "value2", "key3": "value3"}}, config.ExitWithError, "", "Keys test: [key1 key2 key3]"}, {"Filter chain test: {{ .Foo | downcase | replaceAll \" \" \"\" }}", map[string]interface{}{"Foo": "foo BAR baz!"}, config.ExitWithError, "", "Filter chain test: foobarbaz!"}, } diff --git a/test-fixtures/examples-expected-output/java-project/com/acme/example/Example.java b/test-fixtures/examples-expected-output/java-project/com/acme/example/Example.java new file mode 100644 index 00000000..aedc81f6 --- /dev/null +++ b/test-fixtures/examples-expected-output/java-project/com/acme/example/Example.java @@ -0,0 +1,12 @@ +package com.acme.example + +import com.google.common.collect.ImmutableMap; + +public class Example { + public static final int EXAMPLE_INT_CONSTANT = 42 + public static final Map<> EXAMPLE_MAP = ImmutableMap.of("key1", "value1", "key2", "value2", "key3", "value3"); + + public enum ExampleEnum { + Foo, Bar, Baz + } +} \ No newline at end of file diff --git a/test-fixtures/examples-var-files/java-project/vars.yml b/test-fixtures/examples-var-files/java-project/vars.yml new file mode 100644 index 00000000..2f7debe8 --- /dev/null +++ b/test-fixtures/examples-var-files/java-project/vars.yml @@ -0,0 +1,11 @@ +CompanyName: acme +ExampleIntConstant: 42 +IncludeEnum: true +EnumNames: + - Foo + - Bar + - Baz +ExampleMap: + key1: value1 + key2: value2 + key3: value3 \ No newline at end of file From 80741c350fd0c5dc918a4bbb00d9567c14ef1cfa Mon Sep 17 00:00:00 2001 From: Yevgeniy Brikman Date: Tue, 20 Sep 2016 01:16:33 +0100 Subject: [PATCH 7/7] Improve prompt text with examples --- config/get_variables.go | 12 ++++++++++-- templates/template_processor.go | 2 -- variables/types.go | 14 -------------- variables/variables.go | 23 +++++++++++++++++++++++ 4 files changed, 33 insertions(+), 18 deletions(-) diff --git a/config/get_variables.go b/config/get_variables.go index 6e6fc34d..6c5e27b8 100644 --- a/config/get_variables.go +++ b/config/get_variables.go @@ -5,6 +5,7 @@ import ( "github.com/gruntwork-io/boilerplate/util" "github.com/gruntwork-io/boilerplate/errors" "github.com/gruntwork-io/boilerplate/variables" + "strings" ) // Get a value for each of the variables specified in boilerplateConfig, other than those already in existingVariables. @@ -89,10 +90,17 @@ func getVariableFromUser(variable variables.Variable, options *BoilerplateOption if variable.Description() != "" { fmt.Printf(" %s\n", variable.Description()) } + + helpText := []string{ + fmt.Sprintf("type: %s", variable.Type()), + fmt.Sprintf("example value: %s", variable.ExampleValue()), + + } if variable.Default() != nil { - fmt.Printf(" (default: %s)\n", variable.Default()) + helpText = append(helpText, fmt.Sprintf("default: %s", variable.Default())) } - fmt.Printf(" (type: %s, example: %s)\n", variable.Type(), variable.Type().Example()) + + fmt.Printf(" (%s)\n", strings.Join(helpText, ", ")) fmt.Println() value, err := util.PromptUserForInput(" Enter a value") diff --git a/templates/template_processor.go b/templates/template_processor.go index 5030a1b7..00d970b8 100644 --- a/templates/template_processor.go +++ b/templates/template_processor.go @@ -23,8 +23,6 @@ func ProcessTemplate(options *config.BoilerplateOptions) error { return err } - fmt.Printf("variables for template folder %s = %s\n", options.TemplateFolder, boilerplateConfig.Variables) - variables, err := config.GetVariables(options, boilerplateConfig) if err != nil { return err diff --git a/variables/types.go b/variables/types.go index d0fe9918..3f1bcc4f 100644 --- a/variables/types.go +++ b/variables/types.go @@ -37,20 +37,6 @@ func (boilerplateType BoilerplateType) String() string { return string(boilerplateType) } -// Return an example value for this type. This is useful for showing a user the proper syntax to use for that type. -func (boilerplateType BoilerplateType) Example() string { - switch boilerplateType { - case String: return "foo" - case Int: return "42" - case Float: return "3.1415926" - case Bool: return "true" - case List: return "[foo, bar, baz]" - case Map: return "{foo: bar, baz: blah}" - case Enum: return "foo" - default: return "" - } -} - // Custom error types type InvalidBoilerplateType string diff --git a/variables/variables.go b/variables/variables.go index 82b37d84..5e31f338 100644 --- a/variables/variables.go +++ b/variables/variables.go @@ -34,6 +34,12 @@ type Variable interface { // Return a copy of this variable but with the default set to the given value WithDefault(interface{}) Variable + + // Create a human-readable, string representation of the variable + String() string + + // Show an example value that would be valid for this variable + ExampleValue() string } // A private implementation of the Variable interface that forces all users to use our public constructors @@ -146,6 +152,23 @@ func (variable defaultVariable) WithDefault(value interface{}) Variable { return variable } +func (variable defaultVariable) String() string { + return fmt.Sprintf("Variable {Name: '%s', Description: '%s', Type: '%v', Default: '%v', Options: '%v'}", variable.Name(), variable.Description(), variable.Type(), variable.Default(), variable.Options()) +} + +func (variable defaultVariable) ExampleValue() string { + switch variable.Type() { + case String: return "foo" + case Int: return "42" + case Float: return "3.1415926" + case Bool: return "true or false" + case List: return "[foo, bar, baz]" + case Map: return "{foo: bar, baz: blah}" + case Enum: return fmt.Sprintf("must be one of: %s", variable.Options()) + default: return "" + } +} + // Convert the given value to a type that can be used with the given variable. If the type of the value cannot be used // with the type of the variable, return an error. func UnmarshalValueForVariable(value interface{}, variable Variable) (interface{}, error) {