diff --git a/app/commands/cmd_lint.go b/app/commands/cmd_lint.go index e5e7dd0..f4d07a1 100644 --- a/app/commands/cmd_lint.go +++ b/app/commands/cmd_lint.go @@ -43,6 +43,8 @@ func (ctrl *Controller) Lint(ctx *cli.Context) error { q.Prompt.IsConfirm(), q.Prompt.IsSelect(), q.Prompt.IsMultiSelect(), + q.Prompt.IsInputLoop(), + q.Prompt.IsTextInput(), } isAny := false @@ -84,6 +86,15 @@ func (ctrl *Controller) Lint(ctx *cli.Context) error { } } + // Validate injectjons + for _, injection := range pf.Inject { + if injection.Mode != "" { + if injection.Mode != "before" && injection.Mode != "after" { + errs = append(errs, fmt.Errorf("invalid injection mode: %s", injection.Mode)) + } + } + } + for _, err := range errs { log.Error().Err(err).Msg("") } diff --git a/app/scaffold/injector.go b/app/scaffold/injector.go index 23da7b9..e5fd634 100644 --- a/app/scaffold/injector.go +++ b/app/scaffold/injector.go @@ -2,6 +2,7 @@ package scaffold import ( "bufio" + "bytes" "errors" "io" "strings" @@ -23,38 +24,50 @@ func indentation(b string) string { // Inject will read the reader line by line and find the line // that contains the string "at". It will then insert the data // before that line. -func Inject(r io.Reader, data string, at string) ([]byte, error) { - bldr := strings.Builder{} - newline := func(s string) { - bldr.WriteString(s) - bldr.WriteString("\n") +func Inject(r io.Reader, data string, at string, mode Mode) ([]byte, error) { + var buf bytes.Buffer + + // Write a line to the buffer + writeLine := func(line string) { + buf.WriteString(line) + buf.WriteString("\n") + } + + // Write multiple lines with indentation to the buffer + writeLines := func(lines []string, indent string) { + for _, l := range lines { + if l != "" { + writeLine(indent + l) + } + } } - found := false + var ( + inserted = false + found = false + scanner = bufio.NewScanner(r) + linesToInsert = strings.Split(data, "\n") + ) - scanner := bufio.NewScanner(r) + // Loop through each line in the reader for scanner.Scan() { line := scanner.Text() if strings.Contains(line, at) { - // Found the line, insert the data - // before this line - indent := indentation(line) - - lines := strings.Split(data, "\n") - - for _, l := range lines { - if l == "" { - continue - } - - newline(indent + l) + if mode != After { + writeLines(linesToInsert, indentation(line)) + inserted = true } - found = true } - newline(line) + writeLine(line) + + // If in 'after' mode and the insertion point is found, insert after it + if mode == After && found && !inserted { + writeLines(linesToInsert, indentation(line)) + inserted = true + } } if err := scanner.Err(); err != nil { @@ -65,5 +78,5 @@ func Inject(r io.Reader, data string, at string) ([]byte, error) { return nil, ErrInjectMarkerNotFound } - return []byte(bldr.String()), nil + return buf.Bytes(), nil } diff --git a/app/scaffold/injector_test.go b/app/scaffold/injector_test.go index b342c6e..1338a00 100644 --- a/app/scaffold/injector_test.go +++ b/app/scaffold/injector_test.go @@ -7,25 +7,19 @@ import ( "github.com/stretchr/testify/assert" ) -var t1 = `--- -hello world - indented line - # Inject Marker -` - -var t1Want = `--- +func TestInject(t *testing.T) { + const Marker = "# Inject Marker" + const Input = `--- hello world indented line - injected line 1 - injected line 2 # Inject Marker ` -func TestInject(t *testing.T) { type args struct { s string data string at string + mode Mode } tests := []struct { name string @@ -36,25 +30,80 @@ func TestInject(t *testing.T) { { name: "inject", args: args{ - s: t1, + s: Input, data: "injected line 1\ninjected line 2", - at: "# Inject Marker", + at: Marker, }, - want: t1Want, + want: `--- +hello world + indented line + injected line 1 + injected line 2 + # Inject Marker +`, }, { - name: "inject no marker", + name: "inject after", args: args{ - s: t1, + s: Input, data: "injected line 1\ninjected line 2", - at: "# Inject Marker 2", + at: Marker, + mode: After, + }, + want: `--- +hello world + indented line + # Inject Marker + injected line 1 + injected line 2 +`, + }, + { + name: "don't inject empty data", + args: args{ + s: Input, + data: "", + at: Marker, + }, + want: Input, + }, + { + name: "don't inject empty lines", + args: args{ + s: Input, + data: "\n\n\n\n", + at: Marker, + }, + want: Input, + }, + { + name: "inject no marker", + args: args{ + s: Input, + data: "injected", + at: Marker + "invalid", }, wantErr: true, }, + { + name: "preserve manual indentation", + args: args{ + s: Input, + data: " injected", + at: Marker, + mode: After, + }, + want: `--- +hello world + indented line + # Inject Marker + injected +`, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := Inject(strings.NewReader(tt.args.s), tt.args.data, tt.args.at) + got, err := Inject(strings.NewReader(tt.args.s), tt.args.data, tt.args.at, tt.args.mode) switch { case tt.wantErr: diff --git a/app/scaffold/project_scaffold_file.go b/app/scaffold/project_scaffold_file.go index ac3946c..431a061 100644 --- a/app/scaffold/project_scaffold_file.go +++ b/app/scaffold/project_scaffold_file.go @@ -38,10 +38,18 @@ type Rewrite struct { To string `yaml:"to"` } +type Mode string + +const ( + Before Mode = "before" + After Mode = "after" +) + type Injectable struct { Name string `yaml:"name"` Path string `yaml:"path"` At string `yaml:"at"` + Mode Mode `yaml:"mode"` Template string `yaml:"template"` } diff --git a/app/scaffold/render_funcs.go b/app/scaffold/render_funcs.go index 0167fd9..3893fd1 100644 --- a/app/scaffold/render_funcs.go +++ b/app/scaffold/render_funcs.go @@ -334,7 +334,12 @@ func RenderRWFS(eng *engine.Engine, args *RWFSArgs, vars engine.Vars) error { return err } - outbytes, err := Inject(f, out, injection.At) + // Assume that empty string or only whitespace is not a valid injection. + if out == "" || strings.TrimSpace(out) == "" { + continue + } + + outbytes, err := Inject(f, out, injection.At, injection.Mode) if err != nil { return err } diff --git a/docs/docs/.vitepress/config.mts b/docs/docs/.vitepress/config.mts index b8db078..75f224f 100644 --- a/docs/docs/.vitepress/config.mts +++ b/docs/docs/.vitepress/config.mts @@ -6,7 +6,7 @@ export default withMermaid( base: "/scaffold/", title: "Scaffold", description: "A Project and Template Scaffolding Tool", - head: [["link", { rel: "icon", href: "/favicon.webp" }]], + head: [["link", { rel: "icon", href: "/scaffold/favicon.webp" }]], themeConfig: { search: { provider: "local", diff --git a/docs/docs/public/schema.json b/docs/docs/public/schema.json new file mode 100644 index 0000000..480ac08 --- /dev/null +++ b/docs/docs/public/schema.json @@ -0,0 +1,232 @@ +{ + "$schema": "https://json-schema.org/draft-07/schema", + "title": "Scaffold", + "type": "object", + "properties": { + "messages": { + "type": "object", + "properties": { + "pre": { + "description": "Message to display before the scaffold is run", + "type": "string" + }, + "post": { + "description": "Message to display after the scaffold is run", + "type": "string" + } + } + }, + "questions": { + "type": "array", + "items": [ + { + "$ref": "#/definitions/QuestionText" + }, + { + "$ref": "#/definitions/QuestionConfirm" + }, + { + "$ref": "#/definitions/QuestionSelect" + } + ] + }, + "rewrites": { + "type": "array", + "description": "A list of from/to pairs for rewriting files. Specifying this will cause the scaffold to be treated as a template", + "items": { + "type": "object", + "required": [ + "from", + "to" + ], + "properties": { + "from": { + "description": "The file to rewrite (e.g template/models.go)", + "pattern": "^[^/].*", + "type": "string" + }, + "to": { + "description": "the destination of the file (e.g. backend/data/models/{{ .Scaffold.model }}.go)", + "pattern": "^[^/].*", + "type": "string" + } + } + } + }, + "skip": { + "type": "array", + "description": "A list of files to _NOT_ render as templates, files are copied as-is. Supports glob style matches", + "items": { + "type": "string" + } + }, + "inject": { + "type": "array", + "description": "A list of files to inject into. Only available when in template scaffolds", + "items": { + "type": "object", + "required": [ + "name", + "path", + "at", + "template" + ], + "properties": { + "name": { + "description": "name for debugging purposes", + "type": "string" + }, + "path": { + "description": "The relative path to the file to inject into", + "pattern": "^[^/].*", + "type": "string" + }, + "at": { + "description": "The string the file will be injected above", + "type": "string" + }, + "mode": { + "description": "the mode of insertion (before, after)", + "type": "string", + "enum": [ + "before", + "after" + ] + }, + "template": { + "description": "The Go template to inject", + "type": "string" + } + } + } + }, + "computed": { + "type": "object", + "description": "A list of computed values that can be used in templates", + "additionalProperties": { + "type": "string", + "description": "A Go template that will be evaluated when the scaffold is run (keys are available as variables)" + } + } + }, + "definitions": { + "BaseQuestion": { + "type": "object", + "required": [ + "name", + "prompt" + ], + "properties": { + "name": { + "description": "The name of the variable to store the answer in", + "type": "string" + }, + "required": { + "description": "Whether the question is required", + "type": "boolean" + }, + "when": { + "description": "A Go template that evaluates to a boolean. If the result matches 'false' it is skipped" + } + } + }, + "BasePrompt": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "description": "The message to display to the user", + "type": "string" + } + } + }, + "QuestionText": { + "description": "A question that requires a text answer", + "allOf": [ + { + "$ref": "#/definitions/BaseQuestion" + }, + { + "type": "object", + "properties": { + "prompt": { + "$ref": "#/definitions/BasePrompt" + } + } + } + ], + "properties": { + "default": { + "description": "The default value to use if the user does not provide an answer", + "type": "string" + } + } + }, + "QuestionConfirm": { + "description": "A question that requires a yes/no answer", + "allOf": [ + { + "$ref": "#/definitions/BaseQuestion" + }, + { + "type": "object", + "properties": { + "prompt": { + "properties": { + "confirm": { + "description": "The message to display to the user ( [Y/n] ) appended to the end", + "type": "string" + } + } + } + } + } + ] + }, + "QuestionSelect": { + "description": "A question that requires a selection from a list", + "properties": { + "default": { + "description": "Defaults not supported on select questions", + "type": "string", + "not": true + } + }, + "allOf": [ + { + "$ref": "#/definitions/BaseQuestion" + }, + { + "type": "object", + "properties": { + "prompt": { + "required": [ + "options" + ], + "allOf": [ + { + "$ref": "#/definitions/BasePrompt" + } + ], + "properties": { + "options": { + "description": "The list of options to choose from", + "type": "array", + "items": { + "type": "string" + } + }, + "multi": { + "description": "Whether the user can select multiple options", + "type": "boolean" + } + } + } + } + } + ] + } + } +} diff --git a/docs/docs/schema.json b/docs/docs/schema.json deleted file mode 100644 index ae23886..0000000 --- a/docs/docs/schema.json +++ /dev/null @@ -1,224 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft-07/schema", - "title": "Scaffold", - "type": "object", - "properties": { - "messages": { - "type": "object", - "properties": { - "pre": { - "description": "Message to display before the scaffold is run", - "type": "string" - }, - "post": { - "description": "Message to display after the scaffold is run", - "type": "string" - } - } - }, - "questions": { - "type": "array", - "items": [ - { - "$ref": "#/definitions/QuestionText" - }, - { - "$ref": "#/definitions/QuestionConfirm" - }, - { - "$ref": "#/definitions/QuestionSelect" - } - ] - }, - "rewrites": { - "type": "array", - "description": "A list of from/to pairs for rewriting files. Specifying this will cause the scaffold to be treated as a template", - "items": { - "type": "object", - "required": [ - "from", - "to" - ], - "properties": { - "from": { - "description": "The file to rewrite (e.g template/models.go)", - "pattern": "^[^/].*", - "type": "string" - }, - "to": { - "description": "the destination of the file (e.g. backend/data/models/{{ .Scaffold.model }}.go)", - "pattern": "^[^/].*", - "type": "string" - } - } - } - }, - "skip": { - "type": "array", - "description": "A list of files to _NOT_ render as templates, files are copied as-is. Supports glob style matches", - "items": { - "type": "string" - } - }, - "inject": { - "type": "array", - "description": "A list of files to inject into. Only available when in template scaffolds", - "items": { - "type": "object", - "required": [ - "name", - "path", - "at", - "template" - ], - "properties": { - "name": { - "description": "name for debugging purposes", - "type": "string" - }, - "path": { - "description": "The relative path to the file to inject into", - "pattern": "^[^/].*", - "type": "string" - }, - "at": { - "description": "The string the file will be injected above", - "type": "string" - }, - "template": { - "description": "The Go template to inject", - "type": "string" - } - } - } - }, - "computed": { - "type": "object", - "description": "A list of computed values that can be used in templates", - "additionalProperties": { - "type": "string", - "description": "A Go template that will be evaluated when the scaffold is run (keys are available as variables)" - } - } - }, - "definitions": { - "BaseQuestion": { - "type": "object", - "required": [ - "name", - "prompt" - ], - "properties": { - "name": { - "description": "The name of the variable to store the answer in", - "type": "string" - }, - "required": { - "description": "Whether the question is required", - "type": "boolean" - }, - "when": { - "description": "A Go template that evaluates to a boolean. If the result matches 'false' it is skipped" - } - } - }, - "BasePrompt": { - "type": "object", - "required": [ - "message" - ], - "properties": { - "message": { - "description": "The message to display to the user", - "type": "string" - } - } - }, - "QuestionText": { - "description": "A question that requires a text answer", - "allOf": [ - { - "$ref": "#/definitions/BaseQuestion" - }, - { - "type": "object", - "properties": { - "prompt": { - "$ref": "#/definitions/BasePrompt" - } - } - } - ], - "properties": { - "default": { - "description": "The default value to use if the user does not provide an answer", - "type": "string" - } - } - }, - "QuestionConfirm": { - "description": "A question that requires a yes/no answer", - "allOf": [ - { - "$ref": "#/definitions/BaseQuestion" - }, - { - "type": "object", - "properties": { - "prompt": { - "properties": { - "confirm": { - "description": "The message to display to the user ( [Y/n] ) appended to the end", - "type": "string" - } - } - } - } - } - ] - }, - "QuestionSelect": { - "description": "A question that requires a selection from a list", - "properties": { - "default": { - "description": "Defaults not supported on select questions", - "type": "string", - "not": true - } - }, - "allOf": [ - { - "$ref": "#/definitions/BaseQuestion" - }, - { - "type": "object", - "properties": { - "prompt": { - "required": [ - "options" - ], - "allOf": [ - { - "$ref": "#/definitions/BasePrompt" - } - ], - "properties": { - "options": { - "description": "The list of options to choose from", - "type": "array", - "items": { - "type": "string" - } - }, - "multi": { - "description": "Whether the user can select multiple options", - "type": "boolean" - } - } - } - } - } - ] - } - } -} \ No newline at end of file diff --git a/docs/docs/templates/config-reference.md b/docs/docs/templates/config-reference.md index 13e7044..18fa78f 100644 --- a/docs/docs/templates/config-reference.md +++ b/docs/docs/templates/config-reference.md @@ -26,11 +26,11 @@ Whether or not the question is required. ### `when` -A go template will will be evaluated with the previous context to conditionally render the questions. If the template evaluates to `false` the question will not be rendered, otherwise it will be. This is done by using the `strconv.ParseBool` function to parse the result of the template. +A go template will will be evaluated with the previous context to conditionally render the questions. If the template evaluates to `false` the question will not be rendered, otherwise it will be. This is done by using the `strconv.ParseBool` function to parse the result of the template. ::: tip Previous question variables are available at the root level `{{ .previous_name }}` instead of inside the `.Scaffold` namespace. -::: +::: ### `group` @@ -196,6 +196,19 @@ The location to inject the code/text. This is evaluated using the strings.Contai The template to inject into the file. These work the same as scaffold templates. +::: tip +If the template string evaluates to an empty string or _only_ whitespace, the injection will be skipped. +::: + +### `mode` + +The mode to use when injecting the code. This can be one of the following: + +- `before` - Inject the code before the match +- `after` - Inject the code after the match + +`mode` defaults to `before` + **Example** ```yaml diff --git a/go.mod b/go.mod index 45ad183..d9d015f 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/hay-kot/scaffold -go 1.22 +go 1.22.0 require ( github.com/bmatcuk/doublestar/v4 v4.6.1