diff --git a/cmd/pkl-gen-go/pkl-gen-go.go b/cmd/pkl-gen-go/pkl-gen-go.go index dc8c60b..03571d7 100644 --- a/cmd/pkl-gen-go/pkl-gen-go.go +++ b/cmd/pkl-gen-go/pkl-gen-go.go @@ -18,9 +18,12 @@ package main import ( "context" - _ "embed" "errors" "fmt" + "github.com/apple/pkl-go/cmd/pkl-gen-go/generatorsettings" + "github.com/apple/pkl-go/cmd/pkl-gen-go/pkg" + "github.com/apple/pkl-go/pkl" + "github.com/spf13/cobra" "io/fs" "net/url" "os" @@ -29,247 +32,357 @@ import ( "runtime" "runtime/debug" "strings" +) - "github.com/apple/pkl-go/cmd/pkl-gen-go/generatorsettings" - "github.com/apple/pkl-go/cmd/pkl-gen-go/pkg" - "github.com/apple/pkl-go/pkl" - "github.com/spf13/cobra" - "github.com/spf13/pflag" +// Version is the version of pkl-gen-go that is built into the binary. +// +// This gets replaced by ldflags when built through CI, or by init when installed via go install. +var Version = "development" + +func init() { + info, ok := debug.ReadBuildInfo() + if !ok || info.Main.Version == "" || Version != "development" { + return + } + Version = strings.TrimPrefix(info.Main.Version, "v") +} + +// stringErr is a string that satisfies the error interface. +// +// Doing this lets us define constant strings as errors. +type stringErr string + +func (s stringErr) Error() string { + return string(s) +} + +const ( + // errMalformedArgs is returned when the user provides CLI arguments that are badly formed in some way. + errMalformedArgs = stringErr("malformed arguments") + + // errRuntimeCallerFailure is returned when there is a problem using Go's runtime caller functionality. + errRuntimeCallerFailure = stringErr("runtime caller failure") ) -var command = cobra.Command{ - Use: "pkl-gen-go [flags] ", - Short: "Generates Go bindings for a Pkl module", - Long: `Generates Go bindings for a Pkl module. +// long command description +const longDescription = `Generates Go bindings for a Pkl module. PACKAGE MAPPINGS - To generate Go, all Pkl modules must have a known Go package name. The package name - may come from one of three sources: +To generate Go, all Pkl modules must have a known Go package name. The package name +may come from one of three sources: - 1. The @go.Package annotation on a module - 2. A generator settings Pkl file - 3. A --mapping argument +1. The @go.Package annotation on a module +2. A generator settings Pkl file +3. A --mapping argument GENERATOR SETTINGS FILE - Code generation may be configured using a settings file. By default, pkl-gen-go will look - for file called "generator-settings.pkl" in the current working directory, and the path can - be configured using the --generator-settings flag. +Code generation may be configured using a settings file. By default, pkl-gen-go will look +for file called "generator-settings.pkl" in the current working directory, and the path can +be configured using the --generator-settings flag. - The generator settings file should amend module - package://pkg.pkl-lang.org/pkl-go/pkl.golang@#/GeneratorSettings.pkl +The generator settings file should amend module +package://pkg.pkl-lang.org/pkl-go/pkl.golang@#/GeneratorSettings.pkl CONFIGURING OUTPUT PATH - By default, the full path of each module is written as a relative path to the current working - directory. This behavior changes by setting a base path either as a CLI flag, or in the - generator settings file. - - When using a base path, any package that does not belong to the path will be skipped from - code generation. -`, - RunE: func(cmd *cobra.Command, args []string) error { - if printVersion { - fmt.Println(Version) - return nil - } - evaluator, err := newEvaluator() - if err != nil { - return fmt.Errorf("failed to create evaluator: %w", err) - } - if outputPath == "" { - outputPath, err = os.Getwd() - if err != nil { - return err - } - } - if err = pkg.GenerateGo(evaluator, args[0], settings, suppressWarnings, outputPath); err != nil { - _, _ = fmt.Fprint(os.Stderr, err.Error()) - os.Exit(1) - } - return nil - }, - Args: func(cmd *cobra.Command, args []string) error { - if printVersion { - return nil - } - return cobra.ExactArgs(1)(cmd, args) - }, +By default, the full path of each module is written as a relative path to the current working +directory. This behavior changes by setting a base path either as a CLI flag, or in the +generator settings file. + +When using a base path, any package that does not belong to the path will be skipped from +code generation. +` + +// root cobra command for pkl-gen-go +var command = cobra.Command{ + Use: "pkl-gen-go [flags] ", + Short: "Generates Go bindings for a Pkl module", + Long: longDescription, + PreRunE: commandPreRunE, + RunE: commandRunE, } -func newEvaluator() (pkl.Evaluator, error) { - projectDirFlag := "" - if settings.ProjectDir != nil { - if filepath.IsAbs(*settings.ProjectDir) { - projectDirFlag = *settings.ProjectDir - } else { - settingsUri, err := url.Parse(settings.Uri) - if err != nil { - return nil, fmt.Errorf("failed to parse settings.pkl URI: %w", err) - } - projectDirFlag = path.Join(settingsUri.Path, "..", *settings.ProjectDir) - } +// flag names +const ( + flagNamePrintVersion = "version" + flagNameBasePath = "base-path" + flagNameGeneratorSettings = "generator-settings" + flagNameGenerateScript = "generate-script" + flagNameProjectDirname = "project-dir" + flagNameSuppressWarnings = "suppress-format-warning" + flagNameOutputPath = "output-path" + flagNamePackageMappings = "mapping" + flagNameAllowedModules = "allowed-modules" + flagNameAllowedResources = "allowed-resources" + flagNameDryRun = "dry-run" +) + +// initialize command flag set +func init() { + // find the current working directory + cwd, cwdErr := os.Getwd() + handleFatalError(cwdErr) + + // define root command flags + flagSet := command.Flags() + flagSet.Bool(flagNamePrintVersion, false, "Print the version and exit") + flagSet.String(flagNameBasePath, "", "The base path used to determine relative output") + flagSet.String(flagNameGeneratorSettings, "", "path to a generator settings file") + flagSet.String(flagNameGenerateScript, "", "The Generate.pkl script to use") + flagSet.String(flagNameProjectDirname, "", "project directory from which dependency and evaluator settings are loaded") + flagSet.Bool(flagNameSuppressWarnings, false, "Suppress warnings around formatting issues") + flagSet.String(flagNameOutputPath, cwd, "The output directory to write generated sources into") + flagSet.StringToString(flagNamePackageMappings, nil, "The mapping of a Pkl module name to a Go package name") + flagSet.StringSlice(flagNameAllowedModules, nil, "URI patterns that determine which modules can be loaded and evaluated") + flagSet.StringSlice(flagNameAllowedResources, nil, "URI patterns that determine which resources can be loaded and evaluated") + flagSet.Bool(flagNameDryRun, false, "Print out the names of the files that will be generated, but don't write any files") +} + +// context keys +type ckGeneratorSettings struct{} +type ckSuppressWarnings struct{} +type ckOutputPath struct{} + +// command pre-run logic +func commandPreRunE(cmd *cobra.Command, args []string) error { + flagSet := cmd.Flags() + + // resolve flag values + printVersion := unwrapValue(flagSet.GetBool(flagNamePrintVersion)) + basePath := unwrapValue(flagSet.GetString(flagNameBasePath)) + generatorSettingsFilename := unwrapValue(flagSet.GetString(flagNameGeneratorSettings)) + generateScript := unwrapValue(flagSet.GetString(flagNameGenerateScript)) + projectDirname := unwrapValue(flagSet.GetString(flagNameProjectDirname)) + suppressWarnings := unwrapValue(flagSet.GetBool(flagNameSuppressWarnings)) + outputPath := unwrapValue(flagSet.GetString(flagNameOutputPath)) + packageMappings := unwrapValue(flagSet.GetStringToString(flagNamePackageMappings)) + allowedModules := unwrapValue(flagSet.GetStringSlice(flagNameAllowedModules)) + allowedResources := unwrapValue(flagSet.GetStringSlice(flagNameAllowedResources)) + dryRun := unwrapValue(flagSet.GetBool(flagNameDryRun)) + + // if the user wants to print the version and exit, just do that now so that we don't need to waste resources + // loading everything + if printVersion { + fmt.Println(Version) + os.Exit(0) + } + + // expect at exactly one argument + if l := len(args); l != 1 { + return fmt.Errorf("%w: must provide exactly one argument", errMalformedArgs) } - projectDir := findProjectDir(projectDirFlag) - if projectDir == "" { - return pkl.NewEvaluator(context.Background(), evaluatorOptions) + + // initialize generator settings + settings, settingsErr := loadGeneratorSettings(generatorSettingsFilename, projectDirname) + if settingsErr != nil { + return settingsErr } - return pkl.NewProjectEvaluator(context.Background(), projectDir, evaluatorOptions) -} -func evaluatorOptions(opts *pkl.EvaluatorOptions) { - pkl.MaybePreconfiguredOptions(opts) - opts.Logger = pkl.StderrLogger - if len(settings.AllowedModules) > 0 { - opts.AllowedModules = settings.AllowedModules + // load generator settings that are set from flag values + if projectDirname != "" { + settings.ProjectDir = &projectDirname + } + if basePath != "" { + settings.BasePath = basePath + } + if generateScript != "" { + settings.GeneratorScriptPath = generateScript } - if len(settings.AllowedResources) > 0 { - opts.AllowedResources = settings.AllowedResources + if len(packageMappings) > 0 { + settings.PackageMappings = packageMappings } + if len(allowedModules) > 0 { + settings.AllowedModules = allowedModules + } + if len(allowedResources) > 0 { + settings.AllowedResources = allowedResources + } + settings.DryRun = dryRun + + // store context values + ctx := cmd.Context() + ctx = context.WithValue(ctx, ckGeneratorSettings{}, settings) + ctx = context.WithValue(ctx, ckSuppressWarnings{}, suppressWarnings) + ctx = context.WithValue(ctx, ckOutputPath{}, outputPath) + cmd.SetContext(ctx) + + return nil } -var ( - settings *generatorsettings.GeneratorSettings - suppressWarnings bool - outputPath string - printVersion bool -) +// command business logic +func commandRunE(cmd *cobra.Command, args []string) error { -// The version of pkl-gen-go. -// -// This gets replaced by ldflags when built through CI, -// or by init when installed via go install. -var Version = "development" + // pull context values out of the command context + ctx := cmd.Context() + generatorSettings := ctx.Value(ckGeneratorSettings{}).(*generatorsettings.GeneratorSettings) + suppressWarnings := ctx.Value(ckSuppressWarnings{}).(bool) + outputPath := ctx.Value(ckOutputPath{}).(string) -func init() { - info, ok := debug.ReadBuildInfo() - if !ok || info.Main.Version == "" || Version != "development" { - return + // create the main evaluator + evaluator, evalErr := newEvaluator(generatorSettings) + if evalErr != nil { + return evalErr } - Version = strings.TrimPrefix(info.Main.Version, "v") + + // generate Go code using the evaluator, and whatever other directly provided parameters are needed + return pkg.GenerateGo(evaluator, args[0], generatorSettings, suppressWarnings, outputPath) } -func fileExists(filepath string) bool { +func fileExists(filepath string) (bool, error) { _, err := os.Stat(filepath) if errors.Is(err, fs.ErrNotExist) { - return false + return false, nil } else if err != nil { - panic(err) + return false, err + } + return true, nil +} + +// findProjectDir mimics logic for finding project dir in the pkl CLI. +func findProjectDir(dir string) (string, error) { + exists, existsErr := fileExists(filepath.Join(dir, "PklProject")) + if existsErr != nil { + return "", existsErr + } else if exists { + return dir, nil + } + parent := filepath.Dir(dir) + if parent == dir { + return "", nil + } + return findProjectDir(parent) +} + +// closure that produces a functional evaluator options modifier using the given generator settings +func evaluatorOptions(settings *generatorsettings.GeneratorSettings) func(opts *pkl.EvaluatorOptions) { + return func(opts *pkl.EvaluatorOptions) { + pkl.MaybePreconfiguredOptions(opts) + opts.Logger = pkl.StderrLogger + if len(settings.AllowedModules) > 0 { + opts.AllowedModules = settings.AllowedModules + } + if len(settings.AllowedResources) > 0 { + opts.AllowedResources = settings.AllowedResources + } } - return true +} + +// newEvaluator creates the main Pkl evaluator +func newEvaluator(settings *generatorsettings.GeneratorSettings) (pkl.Evaluator, error) { + var projectDir string + + // find the configured project directory + if settings.ProjectDir != nil { + if filepath.IsAbs(*settings.ProjectDir) { + projectDir = *settings.ProjectDir + } else { + settingsUri, err := url.Parse(settings.Uri) + if err != nil { + return nil, fmt.Errorf("failed to parse settings.pkl URI: %w", err) + } + projectDir = path.Join(settingsUri.Path, "..", *settings.ProjectDir) + } + } + + // resolve the project directory + resolvedProjectDir, dirErr := findProjectDir(projectDir) + if dirErr != nil { + return nil, dirErr + } + + // create the appropriate evaluator + if resolvedProjectDir == "" { + return pkl.NewEvaluator(context.Background(), evaluatorOptions(settings)) + } + return pkl.NewProjectEvaluator(context.Background(), resolvedProjectDir, evaluatorOptions(settings)) } //goland:noinspection GoBoolExpressions -func generatorSettingsSource() *pkl.ModuleSource { +func generatorSettingsSource() (*pkl.ModuleSource, error) { if Version == "development" { _, filename, _, ok := runtime.Caller(1) if !ok { - panic("Failed to get path to pkl-gen-go.go") + return nil, fmt.Errorf("%w: could not get path to main Go source file", errRuntimeCallerFailure) } dirPath := filepath.Dir(filename) - return pkl.FileSource(dirPath, "../../codegen/src/GeneratorSettings.pkl") + return pkl.FileSource(dirPath, "../../codegen/src/GeneratorSettings.pkl"), nil } - return pkl.UriSource(fmt.Sprintf("package://pkg.pkl-lang.org/pkl-go/pkl.golang@%s#/GeneratorSettings.pkl", Version)) + return pkl.UriSource(fmt.Sprintf("package://pkg.pkl-lang.org/pkl-go/pkl.golang@%s#/GeneratorSettings.pkl", Version)), nil } -// mimick logic for finding project dir in the pkl CLI. -func doFindProjectDir(dir string) string { - if fileExists(filepath.Join(dir, "PklProject")) { - return dir +func newGeneratorSettingsEvaluator(dirname string) (pkl.Evaluator, error) { + if dirname != "" { + return pkl.NewProjectEvaluator(context.Background(), dirname, pkl.PreconfiguredOptions) } - parent := filepath.Dir(dir) - if parent == dir { - return "" - } - return doFindProjectDir(parent) + return pkl.NewEvaluator(context.Background(), pkl.PreconfiguredOptions) } -func findProjectDir(projectDirFlag string) string { - if projectDirFlag != "" { - return projectDirFlag +func newModuleSource(filename string) (*pkl.ModuleSource, error) { + + // case 1: filename is provided directly + if filename != "" { + return pkl.FileSource(filename), nil } - cwd, err := os.Getwd() - if err != nil { - return "" + + // case 2: filename is empty, but generator-settings.pkl may exist + if exists, existsErr := fileExists("generator-settings.pkl"); existsErr != nil { + return nil, existsErr + } else if exists { + return pkl.FileSource("generator-settings.pkl"), nil } - return doFindProjectDir(cwd) + + // case 3: use the default generator settings source + return generatorSettingsSource() } -// Loads the settings for controlling codegen. -// Uses a Pkl evaluator that is separate from what's used for actually running codegen. -func loadGeneratorSettings(generatorSettingsPath string, projectDirFlag string) (*generatorsettings.GeneratorSettings, error) { - projectDir := findProjectDir(projectDirFlag) - var evaluator pkl.Evaluator - var err error - if projectDir != "" { - evaluator, err = pkl.NewProjectEvaluator(context.Background(), projectDir, pkl.PreconfiguredOptions) - } else { - evaluator, err = pkl.NewEvaluator(context.Background(), pkl.PreconfiguredOptions) +// loadGeneratorSettings loads the settings for controlling code generation. +// +// Uses a Pkl evaluator which is separate from what's used for actually running codegen. +func loadGeneratorSettings(generatorSettingsFilename, projDirname string) (*generatorsettings.GeneratorSettings, error) { + // normalize the project directory + absProjDirname, absErr := filepath.Abs(projDirname) + if absErr != nil { + return nil, absErr } - if err != nil { - panic(err) + + // get the project evaluator + evaluator, evalErr := newGeneratorSettingsEvaluator(absProjDirname) + if evalErr != nil { + return nil, evalErr } - var source *pkl.ModuleSource - if generatorSettingsPath != "" { - source = pkl.FileSource(generatorSettingsPath) - } else if fileExists("generator-settings.pkl") { - source = pkl.FileSource("generator-settings.pkl") - } else { - source = generatorSettingsSource() + + // get the module source + source, sourceErr := newModuleSource(generatorSettingsFilename) + if sourceErr != nil { + return nil, sourceErr } + + // wrap this all together into an instance of GeneratorSettings return generatorsettings.Load(context.Background(), evaluator, source) } -func init() { - flags := command.Flags() - var generatorSettingsPath string - var generateScript string - var mappings map[string]string - var basePath string - var allowedModules []string - var allowedResources []string - var dryRun bool - var projectDir string - flags.StringVar(&generatorSettingsPath, "generator-settings", "", "The path to a generator settings file") - flags.StringVar(&generateScript, "generate-script", "", "The Generate.pkl script to use") - flags.StringToStringVar(&mappings, "mapping", nil, "The mapping of a Pkl module name to a Go package name") - flags.StringVar(&basePath, "base-path", "", "The base path used to determine relative output") - flags.StringVar(&outputPath, "output-path", "", "The output directory to write generated sources into") - flags.BoolVar(&suppressWarnings, "suppress-format-warning", false, "Suppress warnings around formatting issues") - flags.StringSliceVar(&allowedModules, "allowed-modules", nil, "URI patterns that determine which modules can be loaded and evaluated") - flags.StringSliceVar(&allowedResources, "allowed-resources", nil, "URI patterns that determine which resources can be loaded and evaluated") - flags.StringVar(&projectDir, "project-dir", "", "The project directory to load dependency and evaluator settings from") - flags.BoolVar(&dryRun, "dry-run", false, "Print out the names of the files that will be generated, but don't write any files") - flags.BoolVar(&printVersion, "version", false, "Print the version and exit") - var err error - if err = flags.Parse(os.Args); err != nil && !errors.Is(err, pflag.ErrHelp) { - panic(err) - } - settings, err = loadGeneratorSettings(generatorSettingsPath, projectDir) +// handleFatalError handles an error that should end the program. +// +// When a non-zero error value is provided, we exit with a non-zero error code and an error message is written to +// stderr. +func handleFatalError(err error) { if err != nil { - panic(err) + _, _ = fmt.Fprintf(os.Stderr, "fatal error: %s\n", err) + os.Exit(1) } - if generateScript != "" { - settings.GeneratorScriptPath = generateScript - } - if len(mappings) != 0 { - settings.PackageMappings = mappings - } - if basePath != "" { - settings.BasePath = basePath - } - if len(allowedModules) > 0 { - settings.AllowedModules = allowedModules - } - if len(allowedResources) > 0 { - settings.AllowedResources = allowedResources - } - if projectDir != "" { - settings.ProjectDir = &projectDir - } - settings.DryRun = dryRun +} + +// unwrapValue "unwraps" the output of a function that returns a value and an error. +// +// It does this by assuming that any error which comes out of said function can be considered "fatal", in which case +// the program will immediately exit with an error message. +func unwrapValue[T any](t T, err error) T { + handleFatalError(err) + return t } func main() { if err := command.Execute(); err != nil { - panic(err) + _, _ = fmt.Fprintf(os.Stderr, "error: %s", err) + os.Exit(1) } }