package build import ( "context" "fmt" "go/ast" "go/parser" "go/token" "os" "path/filepath" "regexp" "strings" "time" "unicode" "unicode/utf8" "git.golem15.com/golem15/summercms/modules/bonfire" ) // ArtifactResult is the outcome of a make:* command. type ArtifactResult struct { PluginDir string Files []string Hint string } type artifactData struct { PluginID string Ident string Table string Func string Worker string Kind string ConfigDir string PluginPath string Snake string MigrationID string UpSQL string DownSQL string CommandName string Description string Permission string // ModelIdent and ModelSnake name the model an admin controller edits; // ModelImport is the plugin's models package when ModelIdent exists in it. ModelIdent string ModelSnake string ModelImport string } var migrationNow = func() time.Time { return time.Now().UTC() } // MakeModel writes a GORM model and, unless noMigration is set, a create-table migration. func MakeModel(ctx context.Context, startDir, pluginID, name string, noMigration bool) (ArtifactResult, error) { plugin, err := resolvePlugin(startDir, pluginID) if err != nil { return ArtifactResult{}, err } if err := validateExportedIdent(name); err != nil { return ArtifactResult{}, identHint(fmt.Errorf("build: model: %w", err)) } table, err := modelTableName(plugin.ID, name) if err != nil { return ArtifactResult{}, err } modelRel := filepath.Join("models", toSnake(name)+".go") if err := rejectDuplicateFile(plugin.Dir, modelRel); err != nil { return ArtifactResult{}, err } if err := rejectDuplicateIdent(plugin.Dir, "models", name); err != nil { return ArtifactResult{}, err } data := artifactData{ PluginID: plugin.ID, Ident: name, Table: table, } src, err := renderStub("model.go", data) if err != nil { return ArtifactResult{}, err } var created []string rollback := true defer func() { if rollback { for _, path := range created { os.Remove(path) } } }() modelPath := filepath.Join(plugin.Dir, modelRel) if err := writeFile(modelPath, src); err != nil { return ArtifactResult{}, err } created = append(created, modelPath) if !noMigration { migFunc := "Create" + exportedPlural(name) if err := rejectDuplicateIdent(plugin.Dir, "updates", migFunc); err != nil { return ArtifactResult{}, err } migID, migRel, err := nextMigrationFile(plugin.Dir, "create_"+table) if err != nil { return ArtifactResult{}, err } migSrc, err := renderStub("migration.go", artifactData{ Func: migFunc, MigrationID: migID, UpSQL: createTableSQL(table), DownSQL: "DROP TABLE IF EXISTS " + table, }) if err != nil { return ArtifactResult{}, err } migPath := filepath.Join(plugin.Dir, migRel) if err := writeFile(migPath, migSrc); err != nil { return ArtifactResult{}, err } created = append(created, migPath) } if err := finishArtifact(ctx, plugin); err != nil { return ArtifactResult{}, err } rollback = false return ArtifactResult{PluginDir: plugin.Dir, Files: created, Hint: plugin.Hint}, nil } // MakeMigration writes an ordered gormigrate stub in updates/. func MakeMigration(ctx context.Context, startDir, pluginID, name string) (ArtifactResult, error) { plugin, err := resolvePlugin(startDir, pluginID) if err != nil { return ArtifactResult{}, err } if err := validateExportedIdent(name); err != nil { return ArtifactResult{}, identHint(fmt.Errorf("build: migration: %w", err)) } if err := rejectDuplicateIdent(plugin.Dir, "updates", name); err != nil { return ArtifactResult{}, err } slug := toSnake(name) migID, migRel, err := nextMigrationFile(plugin.Dir, slug) if err != nil { return ArtifactResult{}, err } src, err := renderStub("migration.go", artifactData{ Func: name, MigrationID: migID, UpSQL: "SELECT 1", DownSQL: "SELECT 1", }) if err != nil { return ArtifactResult{}, err } path := filepath.Join(plugin.Dir, migRel) if err := writeFile(path, src); err != nil { return ArtifactResult{}, err } if err := finishArtifact(ctx, plugin); err != nil { os.Remove(path) return ArtifactResult{}, err } return ArtifactResult{PluginDir: plugin.Dir, Files: []string{path}, Hint: plugin.Hint}, nil } // MakeCommand writes a bonfire.Command stub in console/. func MakeCommand(ctx context.Context, startDir, pluginID, name string) (ArtifactResult, error) { plugin, err := resolvePlugin(startDir, pluginID) if err != nil { return ArtifactResult{}, err } if err := validateExportedIdent(name); err != nil { return ArtifactResult{}, identHint(fmt.Errorf("build: command: %w", err)) } fn := name + "Command" if err := rejectDuplicateIdent(plugin.Dir, "console", fn); err != nil { return ArtifactResult{}, err } rel := filepath.Join("console", toSnake(name)+".go") if err := rejectDuplicateFile(plugin.Dir, rel); err != nil { return ArtifactResult{}, err } cmdName := pluginName(plugin.ID) + ":" + toSnake(name) src, err := renderStub("command.go", artifactData{ Func: fn, CommandName: cmdName, Description: "TODO: describe " + cmdName, }) if err != nil { return ArtifactResult{}, err } path := filepath.Join(plugin.Dir, rel) if err := writeFile(path, src); err != nil { return ArtifactResult{}, err } if err := finishArtifact(ctx, plugin); err != nil { os.Remove(path) return ArtifactResult{}, err } hint := plugin.Hint if note := nilAppCommandHint(filepath.Join(plugin.Dir, "plugin.go"), fn); note != "" { hint = joinHints(hint, note) } return ArtifactResult{PluginDir: plugin.Dir, Files: []string{path}, Hint: hint}, nil } // zeroArgGeneratedCommands matches a generatedCommands call without // arguments, the form plugin.go had before commands took the app. var zeroArgGeneratedCommands = regexp.MustCompile(`generatedCommands\(\s*\)`) // nilAppCommandHint explains, for a plugin.go that calls generatedCommands() // without the app, that the command fn receives a nil *backpack.App. func nilAppCommandHint(pluginGoPath, fn string) string { body, err := os.ReadFile(pluginGoPath) if err != nil || !zeroArgGeneratedCommands.Match(body) { return "" } return "note: console." + fn + " receives a nil *backpack.App because plugin.go calls generatedCommands() without the app; " + "keep the app in Boot (p.app = app) and return generatedCommands(p.app) from Commands" } func joinHints(a, b string) string { if a == "" { return b } return a + "\n" + b } // MakeJob writes a typed job wrapped in conga.Job; the plugin never // imports River. func MakeJob(ctx context.Context, startDir, pluginID, name string) (ArtifactResult, error) { plugin, err := resolvePlugin(startDir, pluginID) if err != nil { return ArtifactResult{}, err } if err := validateExportedIdent(name); err != nil { return ArtifactResult{}, identHint(fmt.Errorf("build: job: %w", err)) } fn := name + "Job" if err := rejectDuplicateIdent(plugin.Dir, "jobs", fn); err != nil { return ArtifactResult{}, err } rel := filepath.Join("jobs", toSnake(name)+".go") if err := rejectDuplicateFile(plugin.Dir, rel); err != nil { return ArtifactResult{}, err } src, err := renderStub("job.go", artifactData{ Ident: name, Func: fn, Kind: plugin.ID + "." + toSnake(name), }) if err != nil { return ArtifactResult{}, err } path := filepath.Join(plugin.Dir, rel) if err := writeFile(path, src); err != nil { return ArtifactResult{}, err } if err := finishArtifact(ctx, plugin); err != nil { os.Remove(path) return ArtifactResult{}, err } return ArtifactResult{PluginDir: plugin.Dir, Files: []string{path}, Hint: plugin.Hint}, nil } // MakeAdminController writes a pact.AdminController and Winter-shaped YAML. // model names the record type the controller edits; empty means the singular // of name (Posts gives Post). The form and list YAML go under // models//, as in WinterCMS, and NewRecord returns the model // when models/ already declares it. func MakeAdminController(ctx context.Context, startDir, pluginID, name, model string) (ArtifactResult, error) { plugin, err := resolvePlugin(startDir, pluginID) if err != nil { return ArtifactResult{}, err } if err := validateExportedIdent(name); err != nil { return ArtifactResult{}, identHint(fmt.Errorf("build: admin-controller: %w", err)) } if model == "" { model = singularize(name) } if err := validateExportedIdent(model); err != nil { return ArtifactResult{}, identHint(fmt.Errorf("build: admin-controller model: %w", err)) } fn := name + "Controller" if err := rejectDuplicateIdent(plugin.Dir, "controllers", fn); err != nil { return ArtifactResult{}, err } snake := toSnake(name) modelSnake := toSnake(model) rel := filepath.Join("controllers", snake+".go") if err := rejectDuplicateFile(plugin.Dir, rel); err != nil { return ArtifactResult{}, err } configDir := filepath.ToSlash(filepath.Join("controllers", snake)) formRel := filepath.Join("controllers", snake, "config_form.yaml") listRel := filepath.Join("controllers", snake, "config_list.yaml") fieldsRel := filepath.Join("models", modelSnake, "fields.yaml") columnsRel := filepath.Join("models", modelSnake, "columns.yaml") for _, yamlRel := range []string{formRel, listRel, fieldsRel, columnsRel} { if err := rejectDuplicateFile(plugin.Dir, yamlRel); err != nil { return ArtifactResult{}, err } } hasModel, err := declaresStruct(filepath.Join(plugin.Dir, "models"), model) if err != nil { return ArtifactResult{}, err } data := artifactData{ Ident: name, Func: fn, Worker: unexportedIdent(name) + "Admin", Kind: plugin.ID + "." + snake, ConfigDir: configDir, PluginPath: strings.ReplaceAll(plugin.ID, ".", "/"), Snake: snake, Permission: plugin.ID + ".access_" + snake, ModelIdent: model, ModelSnake: modelSnake, } if hasModel { data.ModelImport = plugin.Module + "/models" } src, err := renderStub("admin_controller.go", data) if err != nil { return ArtifactResult{}, err } formSrc, err := renderStub("config_form.yaml", data) if err != nil { return ArtifactResult{}, err } listSrc, err := renderStub("config_list.yaml", data) if err != nil { return ArtifactResult{}, err } fieldsSrc, err := renderStub("fields.yaml", nil) if err != nil { return ArtifactResult{}, err } columnsSrc, err := renderStub("columns.yaml", nil) if err != nil { return ArtifactResult{}, err } var created []string rollback := true defer func() { if rollback { for _, path := range created { os.Remove(path) } } }() goPath := filepath.Join(plugin.Dir, rel) if err := writeFile(goPath, src); err != nil { return ArtifactResult{}, err } created = append(created, goPath) for _, item := range []struct { rel string body []byte }{ {formRel, formSrc}, {listRel, listSrc}, {fieldsRel, fieldsSrc}, {columnsRel, columnsSrc}, } { path := filepath.Join(plugin.Dir, item.rel) if err := writeFile(path, item.body); err != nil { return ArtifactResult{}, err } created = append(created, path) } if err := finishArtifact(ctx, plugin); err != nil { return ArtifactResult{}, err } rollback = false hint := plugin.Hint if !hasModel { hint = joinHints(hint, "note: models."+model+" does not exist yet, so NewRecord returns nil; run summer make:model "+plugin.ID+" "+model+ ", then return &models."+model+"{} from NewRecord in "+filepath.ToSlash(rel)) } declared, err := declaresMethod(plugin.Dir, "AdminFS") if err != nil { return ArtifactResult{}, err } if !declared { hint = joinHints(hint, "note: the plugin declares no AdminFS method; implement pact.AdminAssets with func (p *Plugin) AdminFS() fs.FS { return generatedAdminFS() }, "+ "or the application refuses to start once the admin is enabled") } return ArtifactResult{PluginDir: plugin.Dir, Files: created, Hint: hint}, nil } // declaresStruct reports whether a non-test .go file in dir declares the // exported struct type ident. Every file counts, marked or not. func declaresStruct(dir, ident string) (bool, error) { found := false err := eachGoFile(dir, func(file *ast.File) { if file.Name == nil || file.Name.Name != "models" { return } for _, decl := range file.Decls { gd, ok := decl.(*ast.GenDecl) if !ok || gd.Tok != token.TYPE { continue } for _, spec := range gd.Specs { ts, ok := spec.(*ast.TypeSpec) if !ok || ts.Name == nil || ts.Name.Name != ident { continue } if _, ok := ts.Type.(*ast.StructType); ok { found = true } } } }) return found, err } // declaresMethod reports whether a non-test .go file in dir declares a // method named name. func declaresMethod(dir, name string) (bool, error) { found := false err := eachGoFile(dir, func(file *ast.File) { for _, decl := range file.Decls { if fn, ok := decl.(*ast.FuncDecl); ok && fn.Recv != nil && fn.Name != nil && fn.Name.Name == name { found = true } } }) return found, err } // eachGoFile parses every non-test .go file directly in dir; a missing dir // has none. func eachGoFile(dir string, visit func(*ast.File)) error { entries, err := os.ReadDir(dir) if err != nil { if os.IsNotExist(err) { return nil } return fmt.Errorf("build: read %s: %w", dir, err) } fset := token.NewFileSet() for _, entry := range entries { name := entry.Name() if entry.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") { continue } path := filepath.Join(dir, name) file, err := parser.ParseFile(fset, path, nil, parser.SkipObjectResolution) if err != nil { return fmt.Errorf("build: parse %s: %w", path, err) } visit(file) } return nil } // pluginIDHint explains an invalid plugin ID. func pluginIDHint(err error) error { return bonfire.WithHint(err, "A plugin ID is the vendor and the plugin name in lower case letters and digits, joined by a dot.", "{bin} "+cliCommand("make:model")+" acme.blog ") } // identHint explains an invalid artifact name. func identHint(err error) error { return bonfire.WithHint(err, "The name becomes an exported Go identifier: a capital letter, then letters and digits.", "{bin} "+cliCommand("make:model")+" BlogPost") } // existsHint explains an artifact that is already there. func existsHint(err error) error { return bonfire.WithHint(err, "summer make: never overwrites a file or a name the plugin already has; choose another name, or edit the existing one.", "{bin} "+cliCommand("make:model")+" ") } // pluginMissingHint explains a plugin that summer.yaml does not list and // plugins/ does not hold. func pluginMissingHint(err error, id string) error { return bonfire.WithHint(err, "The plugin is not listed in summer.yaml and has no directory under plugins/.", "{bin} make:plugin "+id+"\n{bin} plugin:add ") } func unexportedIdent(name string) string { r, size := utf8.DecodeRuneInString(name) return string(unicode.ToLower(r)) + name[size:] } type pluginRef struct { Dir string ID string Module string Hint string } func resolvePlugin(startDir, id string) (pluginRef, error) { appDir, err := findAppDir(startDir) if err != nil { return pluginRef{}, err } if id == "" { id, err = inferPluginID(startDir, appDir) if err != nil { return pluginRef{}, err } } if err := ValidatePluginID(id); err != nil { return pluginRef{}, pluginIDHint(fmt.Errorf("build: %w", err)) } pluginDir, err := locatePluginDir(appDir, id) if err != nil { return pluginRef{}, err } if !underRoot(appDir, pluginDir) { return pluginRef{}, fmt.Errorf("build: plugin directory %q is outside the app root", pluginDir) } got, err := pluginIDFromGo(filepath.Join(pluginDir, "plugin.go")) if err != nil { return pluginRef{}, err } if got != id { return pluginRef{}, fmt.Errorf("build: plugin %s has id %s", pluginDir, got) } modPath, err := readModulePath(filepath.Join(pluginDir, "go.mod")) if err != nil { return pluginRef{}, err } if err := validateModulePath(modPath); err != nil { return pluginRef{}, fmt.Errorf("build: plugin module: %w", err) } return pluginRef{ Dir: pluginDir, ID: id, Module: modPath, Hint: accessorHint(filepath.Join(pluginDir, "plugin.go")), }, nil } func inferPluginID(start, appDir string) (string, error) { dir, err := filepath.Abs(start) if err != nil { return "", fmt.Errorf("build: resolve plugin directory: %w", err) } for { candidate := filepath.Join(dir, "plugin.go") if st, err := os.Stat(candidate); err == nil && !st.IsDir() { if !underRoot(appDir, dir) { return "", fmt.Errorf("build: plugin.go at %s is outside the app root", dir) } return pluginIDFromGo(candidate) } if dir == appDir { break } parent := filepath.Dir(dir) if parent == dir { break } dir = parent } return "", bonfire.WithHint(fmt.Errorf("build: plugin id omitted and no plugin.go found from %s", start), "Without a plugin ID, summer "+cliCommand("make:model")+" looks for plugin.go in this directory and above it.", "{bin} "+cliCommand("make:model")+" ") } func locatePluginDir(appDir, id string) (string, error) { m, err := LoadManifest(filepath.Join(appDir, manifestName)) if err != nil { return "", err } for _, p := range m.Plugins { if p.ID != id { continue } if dir := localModuleDir(appDir, p.Module); dir != "" { if st, err := os.Stat(dir); err == nil && st.IsDir() { return dir, nil } } } dir, err := defaultPluginDir(appDir, id) if err != nil { return "", err } if dir == "" { return "", pluginMissingHint(fmt.Errorf("build: plugin %s not found", id), id) } return dir, nil } func finishArtifact(ctx context.Context, plugin pluginRef) error { if err := refreshRegistry(plugin.Dir); err != nil { return err } if err := runGo(ctx, plugin.Dir, "mod", "tidy"); err != nil { return err } return ensureToolchain(filepath.Join(plugin.Dir, "go.mod")) } func accessorHint(pluginGoPath string) string { body, err := os.ReadFile(pluginGoPath) if err != nil { return "" } src := string(body) needed := []string{ "generatedModels", "generatedMigrations", "generatedCommands", "generatedJobs", "generatedAdminControllers", } var missing []string for _, name := range needed { if !strings.Contains(src, name+"(") { missing = append(missing, name+"()") } } if len(missing) == 0 { return "" } return "note: add " + strings.Join(missing, ", ") + " to plugin.go capability methods; handwritten slices should append these accessors" } func validateExportedIdent(name string) error { if name == "" { return fmt.Errorf("name is empty") } if strings.ContainsAny(name, "/\\ \t\r\n\"'`$;&|*?<>()[]{}") { return fmt.Errorf("%q contains invalid characters", name) } if !token.IsIdentifier(name) { return fmt.Errorf("%q is not a valid Go identifier", name) } if !token.IsExported(name) { return fmt.Errorf("%q must be an exported identifier", name) } return nil } func rejectDuplicateFile(pluginDir, rel string) error { path := filepath.Join(pluginDir, rel) if !underRoot(pluginDir, path) { return fmt.Errorf("build: artifact path %q escapes the plugin directory", rel) } if _, err := os.Stat(path); err == nil { return existsHint(fmt.Errorf("build: %s already exists", rel)) } else if !os.IsNotExist(err) { return fmt.Errorf("build: stat %s: %w", path, err) } return nil } func rejectDuplicateIdent(pluginDir, pkg, ident string) error { refs, err := scanGeneratedIdents(filepath.Join(pluginDir, pkg), pkg, identKind(pkg)) if err != nil { return err } for _, ref := range refs { if ref.Ident == ident { return existsHint(fmt.Errorf("build: %s.%s already exists", pkg, ident)) } } return nil } func identKind(pkg string) identScanKind { switch pkg { case "models": return scanStructs case "updates": return scanMigrationFuncs case "console": return scanCommandFuncs case "jobs": return scanJobFuncs case "controllers": return scanAdminFuncs default: return scanStructs } } // migrationStampLen is the length of the timestamp that prefixes every // scaffolded migration file name. const migrationStampLen = 14 const migrationStampLayout = "20060102150405" // nextMigrationFile picks the updates/ file for a new migration. Its // timestamp is now, or one second after the newest migration already in // updates/ when that one is not earlier than now, so the registry, which sorts // by file name, lists migrations in creation order even when several are made // within one second. func nextMigrationFile(pluginDir, slug string) (id, rel string, err error) { if err := validateTableName(slug); err != nil { return "", "", err } updates := filepath.Join(pluginDir, "updates") next := migrationNow().Truncate(time.Second) newest, ok, err := newestMigrationStamp(updates) if err != nil { return "", "", err } if ok && !next.After(newest) { next = newest.Add(time.Second) } ts := next.Format(migrationStampLayout) for i := 0; i < 60; i++ { id = fmt.Sprintf("%s_%s", ts, slug) rel = filepath.Join("updates", id+".go") path := filepath.Join(pluginDir, rel) if !underRoot(pluginDir, path) { return "", "", fmt.Errorf("build: artifact path %q escapes the plugin directory", rel) } _, err := os.Stat(path) if os.IsNotExist(err) { if err := os.MkdirAll(updates, 0o755); err != nil { return "", "", fmt.Errorf("build: mkdir %s: %w", updates, err) } return id, rel, nil } if err != nil { return "", "", fmt.Errorf("build: stat %s: %w", path, err) } next = next.Add(time.Second) ts = next.Format(migrationStampLayout) } return "", "", fmt.Errorf("build: could not allocate a unique migration id for %s", slug) } // newestMigrationStamp returns the latest timestamp among the .go files in // updates whose name starts with 14 digits and an underscore. Other files // (doc.go, tests) are ignored; a missing directory has none. func newestMigrationStamp(updates string) (time.Time, bool, error) { entries, err := os.ReadDir(updates) if err != nil { if os.IsNotExist(err) { return time.Time{}, false, nil } return time.Time{}, false, fmt.Errorf("build: read %s: %w", updates, err) } var newest time.Time found := false for _, entry := range entries { name := entry.Name() if entry.IsDir() || !strings.HasSuffix(name, ".go") || len(name) <= migrationStampLen || name[migrationStampLen] != '_' { continue } stamp := name[:migrationStampLen] if strings.IndexFunc(stamp, func(r rune) bool { return r < '0' || r > '9' }) >= 0 { continue } ts, err := time.Parse(migrationStampLayout, stamp) if err != nil { continue } if !found || ts.After(newest) { newest, found = ts, true } } return newest, found, nil } func modelTableName(pluginID, typeName string) (string, error) { table := strings.ReplaceAll(pluginID, ".", "_") + "_" + pluralSnake(typeName) if err := validateTableName(table); err != nil { return "", err } return table, nil } func validateTableName(name string) error { if name == "" { return fmt.Errorf("build: table name is empty") } for _, r := range name { if r == '_' || (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { continue } return fmt.Errorf("build: table name %q contains invalid characters", name) } return nil } func createTableSQL(table string) string { return "CREATE TABLE " + table + " (\n" + " id BIGSERIAL PRIMARY KEY,\n" + " created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),\n" + " updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()\n" + " )" } func toSnake(ident string) string { var b strings.Builder prevUpper := false for i, r := range ident { upper := unicode.IsUpper(r) if upper && i > 0 && !prevUpper { b.WriteByte('_') } b.WriteRune(unicode.ToLower(r)) prevUpper = upper } return b.String() } func pluralSnake(ident string) string { return pluralize(toSnake(ident)) } func exportedPlural(ident string) string { return snakeToExported(pluralSnake(ident)) } // singularize inverts pluralize on the last word of an exported identifier // (BlogPosts gives BlogPost): ies becomes y; es is dropped after ss, us (but // not ous or aus: Houses gives House), sis, x, ch, sh and zz, and quizzes // gives quiz; a word ending in ss, us or is stays; otherwise a trailing s is // dropped. English has more exceptions than these rules, so the admin // controller scaffold takes an explicit model name too. func singularize(word string) string { lower := strings.ToLower(word) has := func(suffixes ...string) bool { for _, suffix := range suffixes { if strings.HasSuffix(lower, suffix) { return true } } return false } switch { case len(word) > 3 && has("ies"): y := "y" if word[len(word)-1] == 'S' { y = "Y" } return word[:len(word)-3] + y case has("izzes"): return word[:len(word)-3] case has("ouses", "auses"): return word[:len(word)-1] case has("sses", "uses", "sises", "xes", "ches", "shes", "zzes"): return word[:len(word)-2] case has("ss", "us", "is"): return word case len(word) > 1 && has("s"): return word[:len(word)-1] default: return word } } func pluralize(word string) string { switch { case word == "": return word case strings.HasSuffix(word, "s") || strings.HasSuffix(word, "x") || strings.HasSuffix(word, "z") || strings.HasSuffix(word, "ch") || strings.HasSuffix(word, "sh"): return word + "es" case strings.HasSuffix(word, "y"): r, _ := utf8.DecodeLastRuneInString(strings.TrimSuffix(word, "y")) if r != utf8.RuneError && !isVowel(r) { return strings.TrimSuffix(word, "y") + "ies" } return word + "s" default: return word + "s" } } func isVowel(r rune) bool { switch unicode.ToLower(r) { case 'a', 'e', 'i', 'o', 'u': return true default: return false } } func snakeToExported(snake string) string { parts := strings.Split(snake, "_") var b strings.Builder for _, part := range parts { if part == "" { continue } r, size := utf8.DecodeRuneInString(part) b.WriteRune(unicode.ToUpper(r)) b.WriteString(part[size:]) } return b.String() }