Skip to content

Commit

Permalink
feat: Improve templating (#136)
Browse files Browse the repository at this point in the history
This PR will enable writing more powerful templates.

- naive `strings.ReplaceAll()` -> `text/template` from stdlib
- extending `prompts` section of `template.json`

Regarding #135, I will just go ahead and close it, I have spent the past week trying to get timeouts working, then also writing a simpler templating implementation too; non of which seemed like a good idea

---------

Closes #134
Closes #135
Signed-off-by: AlexNg <[email protected]>
  • Loading branch information
caffeine-addictt authored Sep 24, 2024
2 parents 38e5324 + a557fbe commit b110c1a
Show file tree
Hide file tree
Showing 28 changed files with 689 additions and 463 deletions.
6 changes: 3 additions & 3 deletions .prettierignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
# Ignore {{}} syntax in CITATION.cff
template/CITATION.cff
dist/*
template/
!template/template.json
dist/
65 changes: 28 additions & 37 deletions cmd/commands/new.go
Original file line number Diff line number Diff line change
Expand Up @@ -134,37 +134,32 @@ var NewCmd = &cobra.Command{
return
}

licenseTmpl := make(map[string]string, len(license.Wants))
for _, v := range license.Wants {
licenseTmpl[v] = fmt.Sprintf("Value for license %s?", v)
}

// Handle prompts
options.Debugln("resolving prompts...")
extraPrompts := map[string]string{}
extraPrompts := map[string]config.TemplatePrompt{}
if tmpl.Prompts != nil {
for val, ask := range *tmpl.Prompts {
extraPrompts[string(val)] = string(ask)
for _, ask := range *tmpl.Prompts {
extraPrompts[string(ask.Key)] = ask
}
}
if tmpl.Styles != nil && styleInfo.Prompts != nil {
for val, ask := range *styleInfo.Prompts {
extraPrompts[string(val)] = string(ask)
for _, ask := range *styleInfo.Prompts {
extraPrompts[string(ask.Key)] = ask
}
}

licenseTmpl := make(map[string]string, len(license.Wants))
for _, v := range license.Wants {
licenseTmpl[v] = fmt.Sprintf("Value for license %s?", v)
delete(extraPrompts, v)
}
options.Debugf("resolved prompts to: %v\n", extraPrompts)

prompts := make([]*huh.Group, len(extraPrompts))
for n, v := range extraPrompts {
prompts = append(prompts, huh.NewGroup(huh.NewText().Title(v).Validate(func(s string) error {
s = strings.TrimSpace(s)
if s == "" {
return fmt.Errorf("cannot be empty")
}
prompts := make([]*huh.Group, 0, len(extraPrompts))
finalTmpl := make(map[string]any, len(extraPrompts)+len(licenseTmpl))

extraPrompts[n] = s
return nil
})))
for _, v := range extraPrompts {
prompts = append(prompts, huh.NewGroup(v.GetPrompt(finalTmpl)))
}
for n, v := range licenseTmpl {
prompts = append(prompts, huh.NewGroup(huh.NewText().Title(v).Validate(func(s string) error {
Expand All @@ -173,11 +168,13 @@ var NewCmd = &cobra.Command{
return fmt.Errorf("cannot be empty")
}

extraPrompts[n] = s
licenseTmpl[n] = s
finalTmpl[n] = s
return nil
})))
}

options.Debugf("resolved prompt groups to: %v\n", prompts)
if err := huh.NewForm(prompts...).WithAccessible(options.GlobalOpts.Accessible).Run(); err != nil {
cmd.PrintErrln(err)
exitCode = 1
Expand Down Expand Up @@ -228,9 +225,10 @@ var NewCmd = &cobra.Command{

// Handle writing files
cmd.Println("writing files...")
finalTmpl := extraPrompts
finalTmpl["NAME"] = name
finalTmpl["LICENSE"] = license.Spdx
finalTmpl["Name"] = name
finalTmpl["License"] = license.Name
finalTmpl["Spdx"] = license.Spdx
options.Debugf("final template: %v", finalTmpl)

if err := WriteFiles(rootDir, projectRootDir, ignoredPaths.ToSlice(), licenseText, finalTmpl, licenseTmpl); err != nil {
fmt.Printf("failed to write files: %s\n", err)
Expand Down Expand Up @@ -268,7 +266,7 @@ func AddNewCmdFlags(cmd *cobra.Command) {
cmd.Flags().BoolVarP(&options.NewOpts.NoGit, "no-git", "G", options.NewOpts.NoGit, "whether to not initialize git")
}

func WriteFiles(tmpRoot, projectRoot string, paths []string, licenseText string, tmpl, licenseTmpl map[string]string) error {
func WriteFiles(tmpRoot, projectRoot string, paths []string, licenseText string, tmpl map[string]any, licenseTmpl map[string]string) error {
var wg sync.WaitGroup
wg.Add(len(paths) + 1)

Expand Down Expand Up @@ -296,15 +294,15 @@ func WriteFiles(tmpRoot, projectRoot string, paths []string, licenseText string,

tmpFile, err := os.Open(filepath.Clean(tmpPath))
if err != nil {
errChan <- err
errChan <- errors.Join(fmt.Errorf("%s", tmpPath), err)
return
}
defer tmpFile.Close()
options.Debugf("opened file for reading: %s\n", tmpPath)

newFile, err := os.OpenFile(filepath.Clean(newPath), os.O_TRUNC|os.O_CREATE|os.O_WRONLY, utils.FilePerms)
if err != nil {
errChan <- err
errChan <- errors.Join(fmt.Errorf("%s", newPath), err)
return
}
defer newFile.Close()
Expand All @@ -313,13 +311,7 @@ func WriteFiles(tmpRoot, projectRoot string, paths []string, licenseText string,
reader := bufio.NewScanner(tmpFile)
writer := bufio.NewWriter(newFile)
if err := utils.ParseTemplateFile(ctx, tmpl, reader, writer); err != nil {
errChan <- err
return
}

options.Debugf("flushing buffer for %s", newPath)
if err := writer.Flush(); err != nil {
errChan <- err
errChan <- errors.Join(fmt.Errorf("failed to parse template from %s to %s", tmpPath, newPath), err)
return
}

Expand All @@ -344,13 +336,12 @@ func WriteFiles(tmpRoot, projectRoot string, paths []string, licenseText string,
options.Debugf("opened file for writing: %s\n", newPath)

if _, err := newFile.WriteString(newLicenseText); err != nil {
errChan <- err
errChan <- fmt.Errorf("failed to write license text to %s", newPath)
return
}

options.Debugf("flushing buffer for %s", newPath)
if err := newFile.Sync(); err != nil {
errChan <- err
errChan <- fmt.Errorf("failed to flush buffer for %s", newPath)
return
}

Expand Down
5 changes: 3 additions & 2 deletions cmd/config/labels.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package config
import "github.com/caffeine-addictt/waku/cmd/utils/types"

type TemplateLabel []struct {
Color types.HexColor `json:"color"`
Desc string `json:"description,omitempty"`
Name types.CleanString `json:"name"`
Color types.HexColor `json:"color"`
Desc string `json:"description,omitempty"`
}
188 changes: 164 additions & 24 deletions cmd/config/prompts.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,46 +2,186 @@ package config

import (
"fmt"
"regexp"
"strings"

"github.com/caffeine-addictt/waku/cmd/options"
"github.com/caffeine-addictt/waku/cmd/utils/types"
"github.com/charmbracelet/huh"
"github.com/goccy/go-json"
)

// The type of the prompt response
type TemplatePromptType string

const (
TemplatePromptTypeString TemplatePromptType = "str"
TemplatePromptTypeArray TemplatePromptType = "arr"

DefaultTemplatePromptSeparator string = " "
DefaultTemplatePromptFormat string = "*"
)

var (
DefaultTemplatePromptCapture types.RegexString = types.RegexString{Regexp: regexp.MustCompile(`\s*(.*?)\s*`)}
DefaultTemplatePromptValidate types.RegexString = types.RegexString{Regexp: regexp.MustCompile(`.+`)}
)

// TemplatePrompts are the additional things that are formatted
// into the template.
type TemplatePrompts []TemplatePrompt

// TemplatePrompts are the additional things that are formatted
// into the template.
//
// We take the key, strip leading/trailing whitespace and turn it to UPPER.
// The value is used to ask the user for the value.
//
// I.e.
//
// json`{"prompts": {"my_key": "my_value"}}`
// `aaaaa{{MY_KEY}}bbbbb` -> `aaaaamy_valuebbbb`
type TemplatePrompts map[types.CleanString]types.CleanString
// They prompt keys are case sensitive
// and Pacal case is recommended.
type TemplatePrompt struct {
Value any
Format *string `json:"fmt,omitempty"`
Separator *string `json:"sep,omitempty"`
Capture *types.RegexString `json:"capture,omitempty"`
Validate *types.RegexString `json:"validate,omitempty"`
Key types.CleanString `json:"key"`
Ask types.CleanString `json:"ask,omitempty"`
Type TemplatePromptType `json:"type"`
}

// FormattedAsk returns the formatted string for the prompt
func (t *TemplatePrompt) FormattedAsk() string {
s := string(t.Ask)

func (t TemplatePrompts) Validate() error {
keys := make([]types.CleanString, 0, len(t))
for k := range t {
keys = append(keys, k)
if s == "" {
s = t.Key.String()
}

for _, k := range keys {
newK := strings.TrimSpace(k.String())
if newK == "" {
return fmt.Errorf("extra template variable is empty")
if !strings.HasSuffix(s, "?") {
s += "?"
}

if t.Type == TemplatePromptTypeArray {
s += fmt.Sprintf(" [separated by '%s']", *t.Separator)
}

return s
}

func (t *TemplatePrompt) GetPrompt(f map[string]any) *huh.Text {
return huh.NewText().Title(t.FormattedAsk()).Validate(func(s string) error {
if err := t.Set(s); err != nil {
return err
}

f[t.Key.String()] = t.Value
return nil
})
}

// Set sets the value provided by the user
func (t *TemplatePrompt) Set(s string) error {
switch t.Type {
case TemplatePromptTypeString:
val, err := t.formatValue(s)
if err != nil {
return err
}

t.Value = val

case TemplatePromptTypeArray:
vals := strings.Split(s, *t.Separator)
for i, v := range vals {
val, err := t.formatValue(v)
if err != nil {
return err
}

vals[i] = val
}

v := t[k]
newV := strings.TrimSpace(v.String())
if newV == "" {
newV = fmt.Sprintf("Value for '%s' template variable?", newK)
options.Debugf("'%s' template variable ASK is empty, using defaults\n", newK)
t.Value = vals

default:
panic(fmt.Sprintf("unexpected prompt type while setting value: %s", t.Type))
}

return nil
}

func (t *TemplatePrompt) formatValue(val string) (string, error) {
matches := t.Capture.FindStringSubmatch(val)
if matches == nil || len(matches) < 2 {
return "", fmt.Errorf("capture %s did not match '%s'", t.Capture.String(), val)
}

var s strings.Builder
i := 0
for i < len(*t.Format) {
switch (*t.Format)[i] {
case '\\':
if i+1 < len(*t.Format) && (*t.Format)[i+1] == '*' {
s.WriteRune('*')
i += 2
continue
}
case '*':
s.WriteString(val)
default:
s.WriteByte((*t.Format)[i])
}

delete(t, k)
t[types.CleanString(strings.ToUpper(newK))] = types.CleanString(newV)
i++
}

l := s.String()
if !t.Validate.MatchString(l) {
return "", fmt.Errorf("value '%s' did not match '%s'", l, *t.Validate)
}

return l, nil
}

func (t *TemplatePrompt) UnmarshalJSON(data []byte) error {
type Alias TemplatePrompt
var s Alias

if err := json.Unmarshal(data, &s); err != nil {
return err
}

// type
s.Type = TemplatePromptType(strings.ToLower(string(s.Type)))
switch s.Type {
case TemplatePromptTypeString, TemplatePromptTypeArray:
default:
return fmt.Errorf("%s is not a valid prompt type", s.Type)
}

// sep
if s.Separator == nil {
d := string(DefaultTemplatePromptSeparator)
s.Separator = &d
}

// capture
if s.Capture == nil {
s.Capture = &DefaultTemplatePromptCapture
} else if s.Capture.NumSubexp() != 1 {
return fmt.Errorf("capture %s must have 1 sub-expression", s.Capture.String())
}

// format
if s.Format == nil {
d := string(DefaultTemplatePromptFormat)
s.Format = &d
} else if strings.Count(*s.Format, "*")-strings.Count(*s.Format, "\\*") < 1 {
return fmt.Errorf("fmt value '%s' must have at least 1 *", *s.Format)
}

// validate
if s.Validate == nil {
s.Validate = &DefaultTemplatePromptValidate
}

*t = TemplatePrompt(s)
return nil
}
5 changes: 0 additions & 5 deletions cmd/config/styles.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,11 +51,6 @@ func (t *TemplateStyles) Validate(root string) error {
return err
}
}
if style.Prompts != nil {
if err := style.Prompts.Validate(); err != nil {
return err
}
}
}

return nil
Expand Down
5 changes: 0 additions & 5 deletions cmd/config/template.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,11 +44,6 @@ func (t *TemplateJson) Validate(root string) error {
return err
}
}
if t.Prompts != nil {
if err := t.Prompts.Validate(); err != nil {
return err
}
}

return nil
}
2 changes: 1 addition & 1 deletion cmd/global/version.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package global

// The current app version
const Version = "0.2.7"
const Version = "0.3.0"
Loading

0 comments on commit b110c1a

Please sign in to comment.