diff --git a/README.md b/README.md index 55a0541..4897e95 100644 --- a/README.md +++ b/README.md @@ -84,3 +84,44 @@ CRLF = CR LF / LF 7. Alternative `lowest` + + +## Automation +It is possible to specify a configuration using a YAML file to automate the code generation. To install the command: + +```abnf +go install github.com/elimity-com/abnf/cmd/abnf@latest +abnf generate +``` + +The generate command expects to find an `abnf.yml` file in its working directory. This YAML file defines and controls +the code generation. To specify a different YAML file: + +```abnf + abnf -f ./path/to/foo.yml generate +``` + +Creation of an empty YAML file is done by invoking the `init` function. + +### Generating an empty YAML file +```abnf +abnf init +``` + +The `abnf.yml` file is written out in the current working directory. To specify a different location to write out +the file, use the same `-f` flag: + +```abnf +abnf -f ./path/to/foo.yml init +``` + +#### Code Generation Configuration Properties +- **version**: must be defined as "1" +- **spec**: is the path to the ABNF specification file, e.g. `./testdata/core.abnf` +- **gofile**: the name of the Go file to generated, e.g. `core_abnf.go` +- **package**: the name of the package for the Go file generated, e.g. `core` +- **output**: the output path where the package (folder) and Go file are written, e.g. `.` +- **generate**: the type of generation, {`operators`, `alternatives`} +- **verbose**: displays additional debugging information + +The `generate` option either invokes `GenerateABNFAsOperators` or `GenerateABNFAsAlternatives` \ No newline at end of file diff --git a/cmd/abnf/main.go b/cmd/abnf/main.go new file mode 100644 index 0000000..18157ec --- /dev/null +++ b/cmd/abnf/main.go @@ -0,0 +1,10 @@ +package main + +import ( + "github.com/elimity-com/abnf/internal/cmd" + "os" +) + +func main() { + os.Exit(cmd.Do(os.Args[1:], os.Stdin, os.Stdout, os.Stderr)) +} diff --git a/core.yml b/core.yml new file mode 100644 index 0000000..20cdd09 --- /dev/null +++ b/core.yml @@ -0,0 +1,7 @@ +version: "1" +spec: "./testdata/core.abnf" +gofile: "core_abnf.go" +package: "core" +output: "." +verbose: false +generate: "operators" diff --git a/go.mod b/go.mod index 40ef426..f12a9df 100644 --- a/go.mod +++ b/go.mod @@ -4,5 +4,8 @@ go 1.15 require ( github.com/di-wu/regen v1.0.0 + github.com/spf13/cobra v1.5.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect golang.org/x/text v0.3.3 + gopkg.in/yaml.v2 v2.4.0 // indirect ) diff --git a/go.sum b/go.sum index a6a1803..33f9b57 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,15 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/di-wu/regen v1.0.0 h1:OsCF5dbrBS3vdStC4Bc6VVAnw4QN4wDJv1PigwBERhU= github.com/di-wu/regen v1.0.0/go.mod h1:+KqJmSeFLlFKds2W13YGscFiDLGuvLRAYrhBOYf0+/w= +github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.5.0 h1:X+jTBEBqF0bHN+9cSMgmfuvv2VHJ9ezmFNf9Y/XstYU= +github.com/spf13/cobra v1.5.0/go.mod h1:dWXEIy2H428czQCjInthrTRUg7yKbok+2Qi/yBIJoUM= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= golang.org/x/text v0.3.3 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= diff --git a/internal/cmd/cmd.go b/internal/cmd/cmd.go new file mode 100644 index 0000000..2fccbd3 --- /dev/null +++ b/internal/cmd/cmd.go @@ -0,0 +1,103 @@ +package cmd + +import ( + "context" + "fmt" + "github.com/elimity-com/abnf/internal/config" + "github.com/spf13/cobra" + "github.com/spf13/pflag" + "gopkg.in/yaml.v2" + "io" + "os" + "path/filepath" +) + +const Version = "v0.1" + +func Do(args []string, stdin io.Reader, stdout io.Writer, stderr io.Writer) int { + rootCmd := &cobra.Command{Use: "abnf", SilenceUsage: true} + rootCmd.PersistentFlags().StringP("file", "f", "", "specify an alternate config file (default: abnf.yml)") + + rootCmd.AddCommand(genCmd) + rootCmd.AddCommand(initCmd) + rootCmd.AddCommand(versionCmd) + + rootCmd.SetArgs(args) + rootCmd.SetIn(stdin) + rootCmd.SetOut(stdout) + rootCmd.SetErr(stderr) + + ctx := context.Background() + + if err := rootCmd.ExecuteContext(ctx); err == nil { + return 0 + } + return 1 +} + +var versionCmd = &cobra.Command{ + Use: "version", + Short: "Print the abnf version number", + Run: func(cmd *cobra.Command, args []string) { + fmt.Printf("%s\n", Version) + }, +} + +var initCmd = &cobra.Command{ + Use: "init", + Short: "Create an empty abnf.yml settings file", + RunE: func(cmd *cobra.Command, args []string) error { + file := "abnf.yml" + if f := cmd.Flag("file"); f != nil && f.Changed { + file = f.Value.String() + if file == "" { + return fmt.Errorf("file argument is empty") + } + } + if _, err := os.Stat(file); !os.IsNotExist(err) { + return nil + } + blob, err := yaml.Marshal(config.Config{Version: "1", VerboseFlag: false}) + if err != nil { + return err + } + return os.WriteFile(file, blob, 0644) + }, +} + +var genCmd = &cobra.Command{ + Use: "generate", + Short: "Generate Go code from an ABNF specification file", + Run: func(cmd *cobra.Command, args []string) { + stderr := cmd.ErrOrStderr() + dir, name := getConfigPath(stderr, cmd.Flag("file")) + + err := Generate(dir, name, stderr) + if err != nil { + os.Exit(1) + } + }, +} + +func getConfigPath(stderr io.Writer, f *pflag.Flag) (string, string) { + if f != nil && f.Changed { + file := f.Value.String() + if file == "" { + fmt.Fprintln(stderr, "error parsing config: file argument is empty") + os.Exit(1) + } + abspath, err := filepath.Abs(file) + if err != nil { + fmt.Fprintf(stderr, "error parsing config: absolute file path lookup failed: %s\n", err) + os.Exit(1) + } + return filepath.Dir(abspath), filepath.Base(abspath) + } else { + wd, err := os.Getwd() + if err != nil { + fmt.Fprintln(stderr, "error parsing abnf.json: file does not exist") + os.Exit(1) + } + return wd, "" + } +} diff --git a/internal/cmd/generate.go b/internal/cmd/generate.go new file mode 100644 index 0000000..c71b8b8 --- /dev/null +++ b/internal/cmd/generate.go @@ -0,0 +1,67 @@ +package cmd + +import ( + "bytes" + "github.com/elimity-com/abnf" + "github.com/elimity-com/abnf/internal/config" + "io" + "log" + "os" + "path/filepath" +) + +func Generate(dir, filename string, stderr io.Writer) error { + configPath, configuration, err := config.ReadConfig(stderr, dir, filename) + if err != nil { + return err + } + + if configuration.VerboseFlag { + log.Printf("Config Path: %v, Spec: %v, Package: %v, Output: %v", configPath, configuration.SpecFile, configuration.PackageName, configuration.OutputPath) + } + + rawABNF, err := os.ReadFile(configuration.SpecFile) + if err != nil { + log.Fatalf("Error %v", err) + } + + g := abnf.CodeGenerator{ + PackageName: configuration.PackageName, + RawABNF: rawABNF, + } + buf := new(bytes.Buffer) + + if configuration.Generation == "alternatives" { + g.GenerateABNFAsAlternatives(buf) + } else { + g.GenerateABNFAsOperators(buf) + } + + assembledOutputPath := filepath.Join(configuration.OutputPath, configuration.PackageName) + + if configuration.VerboseFlag { + log.Printf("Assembled output path: %v", assembledOutputPath) + } + + err = os.MkdirAll(assembledOutputPath, 0775) + if err != nil { + log.Fatalf("Unable to make assembled output path directory structure: %v", err) + } + + assembledFilePath := filepath.Join(assembledOutputPath, configuration.GoFileName) + if configuration.VerboseFlag { + log.Printf("Assembled Go file path: %v", assembledFilePath) + } + + err = os.WriteFile(assembledFilePath, buf.Bytes(), 0644) + if err != nil { + log.Fatalf("Error writing Go source code to file: %v", err) + } + + if configuration.VerboseFlag { + log.Printf("%v ABNF specification successfully processed", configuration.SpecFile) + log.Printf("Output Directory: %v", configuration.OutputPath) + log.Printf("Go Code %v/%v", configuration.PackageName, configuration.GoFileName) + } + return nil +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..73723b6 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,173 @@ +package config + +import ( + "bytes" + "errors" + "fmt" + "gopkg.in/yaml.v2" + "io" + "log" + "os" + "path/filepath" +) + +var errMissingVersion = errors.New("no version number") +var errNoSpecFile = errors.New("no spec file") +var errNoGoFile = errors.New("no go file") +var errNoPackage = errors.New("no package name") +var errNoGeneration = errors.New("no generation method") +var errNoOutPath = errors.New("no output path") +var errUnknownVersion = errors.New("invalid version number") + +const errMessageNoVersion = `The configuration file must have a version number. +Set the version to 1 at the top of abnf.json: + +{ + "version": "1" + ... +} +` + +const errMessageUnknownVersion = `The configuration file has an invalid version number. +The only supported version is "1". +` + +const errMessageNoGenerator = `No generation option was configured; this is a required configuration. +The value must be one of "operators" or "alternatives". +` + +const errMessageNoPackages = `No Go package name was configured; this is a required configuration` + +const errMessageNoGoFile = `No Go file name was configured; this is a required configuration` + +type Config struct { + Version string `json:"version" yaml:"version"` + SpecFile string `json:"spec" yaml:"spec"` + Generation string `json:"generate" yaml:"generate"` + PackageName string `json:"package" yaml:"package"` + GoFileName string `json:"gofile" yaml:"gofile"` + OutputPath string `json:"output" yaml:"output"` + VerboseFlag bool `json:"verbose" yaml:"verbose"` +} + +type versionSetting struct { + Number string `json:"version" yaml:"version"` +} + +func parseConfig(rd io.Reader) (Config, error) { + var buf bytes.Buffer + var config Config + var version versionSetting + + ver := io.TeeReader(rd, &buf) + dec := yaml.NewDecoder(ver) + if err := dec.Decode(&version); err != nil { + return config, err + } + if version.Number == "" { + return config, errMissingVersion + } + switch version.Number { + case "1": + return v1ParseConfig(&buf) + default: + return config, errUnknownVersion + } +} + +func v1ParseConfig(rd io.Reader) (Config, error) { + dec := yaml.NewDecoder(rd) + + var config Config + if err := dec.Decode(&config); err != nil { + return config, err + } + if config.Version == "" { + return config, errMissingVersion + } + if config.Version != "1" { + return config, errUnknownVersion + } + if config.GoFileName == "" { + return config, errNoGoFile + } + if config.SpecFile == "" { + return config, errNoSpecFile + } + if config.Generation == "" { + return config, errNoGeneration + } + if config.PackageName == "" { + return config, errNoPackage + } + if config.OutputPath == "" { + return config, errNoOutPath + } + + return config, nil +} + +func ReadConfig(stderr io.Writer, dir, filename string) (string, *Config, error) { + configPath := "" + if filename != "" { + configPath = filepath.Join(dir, filename) + } else { + var yamlMissing, jsonMissing bool + yamlPath := filepath.Join(dir, "abnf.yml") + jsonPath := filepath.Join(dir, "abnf.json") + + if _, err := os.Stat(yamlPath); os.IsNotExist(err) { + yamlMissing = true + } + if _, err := os.Stat(jsonPath); os.IsNotExist(err) { + jsonMissing = true + } + + if yamlMissing && jsonMissing { + fmt.Fprintln(stderr, "error parsing configuration files. abnf.yml or abnf.json: file does not exist") + return "", nil, errors.New("config file missing") + } + + if !yamlMissing && !jsonMissing { + fmt.Fprintln(stderr, "error: both abnf.json and abnf.yml files present") + return "", nil, errors.New("abnf.json and abnf.yml present") + } + + configPath = yamlPath + if yamlMissing { + configPath = jsonPath + } + } + + base := filepath.Base(configPath) + blob, err := os.ReadFile(configPath) + if err != nil { + fmt.Fprintf(stderr, "error parsing %s: file does not exist\n", base) + return "", nil, err + } + + conf, err := parseConfig(bytes.NewReader(blob)) + if err != nil { + errMessage := fmt.Sprintf("error parsing %s: %s\n", base, err) + switch err { + case errMissingVersion: + errMessage = errMessageNoVersion + case errUnknownVersion: + errMessage = errMessageUnknownVersion + case errNoPackage: + errMessage = errMessageNoPackages + case errNoGeneration: + errMessage = errMessageNoGenerator + case errNoGoFile: + errMessage = errMessageNoGoFile + } + + _, ferr := fmt.Fprintf(stderr, errMessage) + if ferr != nil { + log.Fatalln("Error occurred", ferr) + } + return "", nil, err + } + + return configPath, &conf, nil +}