├── .github └── workflows │ ├── plugin_nodejs.yml │ └── test.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── cmd └── protolock │ ├── main.go │ └── plugins.go ├── commit.go ├── config.go ├── extend ├── extend_test.go └── plugin.go ├── go.mod ├── go.sum ├── hints.go ├── hints_test.go ├── init.go ├── order_test.go ├── parse.go ├── parse_test.go ├── plugin-samples ├── plugin-sample-error │ └── main.go ├── plugin-sample-js │ ├── example.data.json │ ├── main.js │ └── package.json ├── plugin-sample-wasm │ ├── go.mod │ ├── go.sum │ ├── main.go │ └── status.wasm └── plugin-sample │ └── main.go ├── proto.lock ├── protopath.go ├── protopath_test.go ├── release.sh ├── report.go ├── rules.go ├── rules_test.go ├── status.go ├── testdata ├── getProtoFiles │ ├── directory.proto │ │ └── test.non-proto │ ├── exclude.proto │ ├── exclude │ │ └── test.proto │ └── include │ │ ├── exclude.proto │ │ └── include.proto ├── imports_options.proto └── test.proto ├── uptodate.go └── uptodate_test.go /.github/workflows/plugin_nodejs.yml: -------------------------------------------------------------------------------- 1 | name: Node JS Plugin Test 2 | on: [push, pull_request] 3 | 4 | jobs: 5 | test: 6 | runs-on: ubuntu-latest 7 | env: 8 | GOPATH: ${{ github.workspace }} 9 | GOBIN: ${{ github.workspace }}/bin 10 | GO111MODULE: "on" 11 | defaults: 12 | run: 13 | working-directory: ${{ env.GOPATH }}/src/github.com/nilslice/protolock 14 | name: plugin_nodejs 15 | steps: 16 | - uses: actions/checkout@v4 17 | with: 18 | fetch-depth: 1 19 | path: ${{ env.GOPATH }}/src/github.com/nilslice/protolock 20 | - name: Set Up Node 21 | uses: actions/setup-node@v4 22 | with: 23 | node-version: 8 24 | - name: "Run test" 25 | run: | 26 | set +o pipefail 27 | cd plugin-samples/plugin-sample-js/ 28 | WARNINGS=$(node main.js < example.data.json) 29 | echo $WARNINGS | grep '{"filepath":"path/to/file.proto","message":"Something bad happened."}' 30 | echo $WARNINGS | grep '{"filepath":"path/to/another.proto","message":"Something else bad happened."}' 31 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Protolock source & CLI test 2 | on: [push, pull_request] 3 | 4 | jobs: 5 | test: 6 | runs-on: ubuntu-latest 7 | env: 8 | GOPATH: ${{ github.workspace }} 9 | GOBIN: ${{ github.workspace }}/bin 10 | GO111MODULE: "on" 11 | defaults: 12 | run: 13 | working-directory: ${{ env.GOPATH }}/src/github.com/nilslice/protolock 14 | name: build 15 | steps: 16 | - uses: actions/checkout@v4 17 | with: 18 | fetch-depth: 1 19 | path: ${{ env.GOPATH }}/src/github.com/nilslice/protolock 20 | - name: Set Up Go 21 | uses: actions/setup-go@v4 22 | with: 23 | go-version: '1.20' 24 | - name: fetch depenencies, test code 25 | run: | 26 | go get -v -d ./... 27 | go test -v -race ./... 28 | - name: install binary, test commands 29 | run: | 30 | go install ./... 31 | protolock 32 | stat proto.lock 33 | cat proto.lock | grep "testdata:/:test.proto" 34 | protolock status 35 | protolock commit 36 | protolock status --plugins=_not-a-plugin_ | grep "executable file not found" 37 | - name: check output using plugin-sample-error 38 | run: | 39 | set +o pipefail 40 | 41 | protolock status --plugins=plugin-sample-error | grep "some error" 42 | - name: check output using plugin-sample 43 | run: | 44 | set +o pipefail 45 | 46 | WARNINGS=$(protolock status --plugins=plugin-sample | wc -l) 47 | if [ "$WARNINGS" != 2 ]; then 48 | exit 1 49 | fi 50 | - name: check output using plugin-sample-wasm 51 | run: | 52 | set +o pipefail 53 | 54 | protolock status --plugins=plugin-samples/plugin-sample-wasm/status.wasm | grep "Extism plugin ran" 55 | - name: check output using multiple plugins, with one error expected 56 | run: | 57 | protolock status \ 58 | --plugins=plugin-sample,plugin-sample-error,plugin-samples/plugin-sample-wasm/status.wasm \ 59 | | grep "some error" 60 | 61 | protolock status --plugins=plugin-sample-error,plugin-sample | grep "some error" 62 | - name: check output using multiple plugins with errors 63 | run: | 64 | set +o pipefail 65 | 66 | ERRS=$(protolock status --plugins=plugin-sample-error,plugin-sample-error | grep "some error" | wc -l) 67 | if [ "$ERRS" != 4 ]; then # (4 = 2 * 2, since errors are now reported using 2 lines) 68 | exit 1 69 | fi 70 | MOREERRS=$(protolock status --plugins=plugin-sample-error,plugin-sample-error,plugin-sample-error | grep "some error" | wc -l) 71 | if [ "$MOREERRS" != 6 ]; then # (6 = 3 * 2, since errors are now reported using 2 lines) 72 | exit 1 73 | fi 74 | - name: remove a test proto file, expect violations.txt to contain data from plugin-sample 75 | run: | 76 | set +o pipefail 77 | 78 | rm testdata/test.proto 79 | protolock status --plugins=plugin-sample || true # let this fail, don't stop CI 80 | stat violations.txt 81 | cat violations.txt | grep "Encountered changes in violation of: NoRemovingFieldsWithoutReserve" 82 | - name: check if proto.lock is up-to-date with the .proto files in the tree 83 | run: | 84 | set +o pipefail 85 | 86 | cat >testdata/newProto.proto < 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | * Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | * Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # protolock 2 | 3 | Track your .proto files and prevent changes to messages and services which impact API compatibility. 4 | 5 | ![Tests](https://github.com/nilslice/protolock/actions/workflows/test.yml/badge.svg) 6 | [![GoDoc](https://img.shields.io/badge/godoc-reference-blue.svg?style=flat)](https://pkg.go.dev/github.com/nilslice/protolock) 7 | 8 | ## Why 9 | 10 | Ever _accidentally_ break your API compatibility while you're busy fixing problems? You may have forgotten to reserve the field number of a message or you re-ordered fields after removing a property. Maybe a new team member was not familiar with the backward-compatibility of Protocol Buffers and made an easy mistake. 11 | 12 | `protolock` attempts to help prevent this from happening. 13 | 14 | ## Overview 15 | 16 | 1. **Initialize** your repository: 17 | 18 | $ protolock init 19 | # creates a `proto.lock` file 20 | 21 | 3. **Add changes** to .proto messages or services, verify no breaking changes made: 22 | 23 | $ protolock status 24 | CONFLICT: "Channel" is missing ID: 108, which had been reserved [path/to/file.proto] 25 | CONFLICT: "Channel" is missing ID: 109, which had been reserved [path/to/file.proto] 26 | 27 | 2. **Commit** a new state of your .protos (rewrites `proto.lock` if no warnings): 28 | 29 | $ protolock commit 30 | # optionally provide --force flag to disregard warnings 31 | 32 | 4. **Integrate** into your protobuf compilation step: 33 | 34 | $ protolock status && protoc -I ... 35 | 36 | In all, prevent yourself from compiling your protobufs and generating code if breaking changes have been made. 37 | 38 | **Recommended:** commit the output `proto.lock` file into your version control system 39 | 40 | ## Install 41 | If you have [Go](https://golang.org) installed, you can install `protolock` by running: 42 | 43 | - Go >= 1.17: 44 | 45 | ```bash 46 | go install github.com/nilslice/protolock/cmd/protolock@latest 47 | ``` 48 | 49 | - Go < 1.17: 50 | 51 | ```bash 52 | go get github.com/nilslice/protolock/cmd/protolock 53 | ``` 54 | 55 | Otherwise, download a pre-built binary for Windows, macOS, or Linux from the [latest release](https://github.com/nilslice/protolock/releases/latest) page. 56 | 57 | ## Usage 58 | ``` 59 | protolock [options] 60 | 61 | Commands: 62 | -h, --help, help display the usage information for protolock 63 | init initialize a proto.lock file from current tree 64 | status check for breaking changes and report conflicts 65 | commit rewrite proto.lock file with current tree if no conflicts (--force to override) 66 | 67 | Options: 68 | --strict [true] enable strict mode and enforce all built-in rules 69 | --debug [false] enable debug mode and output debug messages 70 | --ignore comma-separated list of filepaths to ignore 71 | --force [false] forces commit to rewrite proto.lock file and disregards warnings 72 | --plugins comma-separated list of executable protolock plugin names 73 | --lockdir [.] directory of proto.lock file 74 | --protoroot [.] root of directory tree containing proto files 75 | --uptodate [false] enforce that proto.lock file is up-to-date with proto files 76 | ``` 77 | 78 | ## Related Projects & Users 79 | - [Apache Ozone](https://github.com/apache/ozone) 80 | - [Fanatics](https://github.com/fanatics) 81 | - [Salesforce](https://github.com/salesforce/proto-backwards-compat-maven-plugin) 82 | - [Istio](https://github.com/istio/api) 83 | - [Lyft](https://github.com/lyft) 84 | - [Envoy](https://github.com/envoyproxy) 85 | - [Netflix](https://github.com/Netflix) 86 | - [VMware](https://github.com/vmware/hamlet) 87 | - [Storj](https://github.com/storj/storj) 88 | - [Token.io](https://github.com/tokenio/merchant-proxy) 89 | - [Openbase](https://github.com/openbase/type) 90 | - [Zeebee](https://github.com/zeebe-io/zeebe) 91 | 92 | ## Rules Enforced 93 | 94 | #### No Using Reserved Fields 95 | Compares the current vs. updated Protolock definitions and will return a list of 96 | warnings if any message's previously reserved fields or IDs are now being used 97 | as part of the same message. 98 | 99 | #### No Removing Reserved Fields 100 | Compares the current vs. updated Protolock definitions and will return a list of 101 | warnings if any reserved field has been removed. 102 | 103 | **Note:** This rule is not enforced when strict mode is disabled. 104 | 105 | 106 | #### No Changing Field IDs 107 | Compares the current vs. updated Protolock definitions and will return a list of 108 | warnings if any field ID number has been changed. 109 | 110 | 111 | #### No Changing Field Types 112 | Compares the current vs. updated Protolock definitions and will return a list of 113 | warnings if any field type has been changed. 114 | 115 | 116 | #### No Changing Field Names 117 | Compares the current vs. updated Protolock definitions and will return a list of 118 | warnings if any message's previous fields have been renamed. 119 | 120 | **Note:** This rule is not enforced when strict mode is disabled. 121 | 122 | #### No Removing Fields Without Reserve 123 | Compares the current vs. updated Protolock definitions and will return a list of 124 | warnings if any field has been removed without a corresponding reservation of 125 | that field name or ID. 126 | 127 | #### No Removing RPCs 128 | Compares the current vs. updated Protolock definitions and will return a list of 129 | warnings if any RPCs provided by a Service have been removed. 130 | 131 | **Note:** This rule is not enforced when strict mode is disabled. 132 | 133 | #### No Changing RPC Signature 134 | Compares the current vs. updated Protolock definitions and will return a list of 135 | warnings if any RPC signature has been changed while using the same name. 136 | 137 | --- 138 | 139 | ## Docker 140 | 141 | ```sh 142 | docker pull nilslice/protolock:latest 143 | docker run -v $(pwd):/protolock -w /protolock nilslice/protolock init 144 | ``` 145 | 146 | --- 147 | 148 | ## Plugins 149 | The default rules enforced by `protolock` may not cover everything you want to 150 | do. If you have custom checks you'd like run on your .proto files, create a 151 | plugin, and have `protolock` run it and report your warnings. Read the wiki to 152 | learn more about [creating and using plugins](https://github.com/nilslice/protolock/wiki/Plugins). 153 | 154 | --- 155 | 156 | ## Contributing 157 | Please feel free to make pull requests with better support for various rules, 158 | optimized code and overall tests. Filing an issue when you encounter a bug or 159 | any unexpected behavior is very much appreciated. 160 | 161 | For current issues, see: [open issues](https://github.com/nilslice/protolock/issues) 162 | 163 | --- 164 | 165 | ## Acknowledgement 166 | 167 | Thank you to Ernest Micklei for his work on the excellent parser heavily relied upon by this tool and many more: [https://github.com/emicklei/proto](https://github.com/emicklei/proto) 168 | -------------------------------------------------------------------------------- /cmd/protolock/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "io" 7 | "os" 8 | 9 | "github.com/nilslice/protolock" 10 | ) 11 | 12 | const info = `Track your .proto files and prevent changes to messages and services which impact API compatibility. 13 | 14 | Copyright Steve Manuel 15 | Released under the BSD-3-Clause license. 16 | ` 17 | 18 | const usage = ` 19 | Usage: 20 | protolock [options] 21 | 22 | Commands: 23 | -h, --help, help display the usage information for protolock 24 | init initialize a proto.lock file from current tree 25 | status check for breaking changes and report conflicts 26 | commit rewrite proto.lock file with current tree if no conflicts (--force to override) 27 | 28 | Options: 29 | --strict [true] enable strict mode and enforce all built-in rules 30 | --debug [false] enable debug mode and output debug messages 31 | --ignore comma-separated list of filepaths to ignore 32 | --force [false] forces commit to rewrite proto.lock file and disregards warnings 33 | --plugins comma-separated list of executable protolock plugin names 34 | --lockdir [.] directory of proto.lock file 35 | --protoroot [.] root of directory tree containing proto files 36 | --uptodate [false] enforce that proto.lock file is up-to-date with proto files 37 | ` 38 | 39 | var ( 40 | options = flag.NewFlagSet("options", flag.ExitOnError) 41 | debug = options.Bool("debug", false, "toggle debug mode for verbose output") 42 | strict = options.Bool("strict", true, "enable strict mode and enforce all built-in rules") 43 | ignore = options.String("ignore", "", "comma-separated list of filepaths to ignore") 44 | force = options.Bool("force", false, "force commit to rewrite proto.lock file and disregard warnings") 45 | plugins = options.String("plugins", "", "comma-separated list of executable protolock plugin names") 46 | lockDir = options.String("lockdir", ".", "directory of proto.lock file") 47 | protoRoot = options.String("protoroot", ".", "root of directory tree containing proto files") 48 | upToDate = options.Bool("uptodate", false, "enforce that proto.lock file is up-to-date with proto files") 49 | ) 50 | 51 | func main() { 52 | // exit if no command (i.e. help, -h, --help, init, status, or commit) 53 | if len(os.Args) < 2 { 54 | fmt.Print(info + usage) 55 | os.Exit(0) 56 | } 57 | 58 | // parse and set options flags 59 | options.Parse(os.Args[2:]) 60 | protolock.SetDebug(*debug) 61 | protolock.SetStrict(*strict) 62 | 63 | cfg, err := protolock.NewConfig( 64 | *lockDir, 65 | *protoRoot, 66 | *ignore, 67 | *upToDate, 68 | *debug, 69 | ) 70 | if err != nil { 71 | fmt.Println(err) 72 | os.Exit(1) 73 | } 74 | 75 | // switch through known commands 76 | switch os.Args[1] { 77 | case "-h", "--help", "help": 78 | fmt.Print(usage) 79 | 80 | case "init": 81 | r, err := protolock.Init(*cfg) 82 | if err != nil { 83 | fmt.Println(err) 84 | os.Exit(1) 85 | } 86 | 87 | err = saveToLockFile(*cfg, r) 88 | if err != nil { 89 | fmt.Println(err) 90 | os.Exit(1) 91 | } 92 | 93 | case "commit": 94 | // if force option is false (default), then disallow commit if 95 | // there are any warnings encountered by runing a status check. 96 | if !*force { 97 | status(cfg) 98 | } 99 | 100 | r, err := protolock.Commit(*cfg) 101 | if err != nil { 102 | fmt.Println(err) 103 | os.Exit(1) 104 | } 105 | 106 | err = saveToLockFile(*cfg, r) 107 | if err != nil { 108 | fmt.Println(err) 109 | os.Exit(1) 110 | } 111 | 112 | case "status": 113 | status(cfg) 114 | 115 | default: 116 | os.Exit(0) 117 | } 118 | } 119 | 120 | func status(cfg *protolock.Config) { 121 | report, err := protolock.Status(*cfg) 122 | if err == protolock.ErrOutOfDate { 123 | fmt.Println(logPrefix, "error:", err, "run 'protolock commit'") 124 | // only exit if flag provided for backwards compatibility 125 | if cfg.UpToDate { 126 | os.Exit(2) 127 | } 128 | // don't report the error twice 129 | err = nil 130 | } 131 | if err != protolock.ErrWarningsFound && err != nil { 132 | fmt.Println(logPrefix, "error:", err) 133 | os.Exit(1) 134 | } 135 | // if plugins are provided, attempt to execute each as a executable 136 | // located in the user's OS executable path as reported by stdlib's 137 | // exec.LookPath func 138 | if *plugins != "" { 139 | report, err = runPlugins(*plugins, report, *debug) 140 | if err != nil { 141 | fmt.Println(logPrefix, "error:", err) 142 | os.Exit(1) 143 | } 144 | } 145 | 146 | code, err := protolock.HandleReport(report, os.Stdout, err) 147 | if err != protolock.ErrWarningsFound && err != nil { 148 | fmt.Println(logPrefix, "error:", err) 149 | os.Exit(1) 150 | } 151 | 152 | if code != 0 { 153 | os.Exit(code) 154 | } 155 | } 156 | 157 | func saveToLockFile(cfg protolock.Config, r io.Reader) error { 158 | lockfile, err := os.Create(cfg.LockFilePath()) 159 | if err != nil { 160 | return err 161 | } 162 | defer lockfile.Close() 163 | 164 | _, err = io.Copy(lockfile, r) 165 | if err != nil { 166 | return err 167 | } 168 | 169 | return nil 170 | } 171 | -------------------------------------------------------------------------------- /cmd/protolock/plugins.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "errors" 8 | "fmt" 9 | "os/exec" 10 | "strings" 11 | "sync" 12 | 13 | extism "github.com/extism/go-sdk" 14 | "github.com/nilslice/protolock" 15 | "github.com/nilslice/protolock/extend" 16 | ) 17 | 18 | const logPrefix = "[protolock]" 19 | 20 | func runPlugins( 21 | pluginList string, 22 | report *protolock.Report, 23 | debug bool, 24 | ) (*protolock.Report, error) { 25 | inputData := &bytes.Buffer{} 26 | 27 | err := json.NewEncoder(inputData).Encode(&extend.Data{ 28 | Current: report.Current, 29 | Updated: report.Updated, 30 | ProtolockWarnings: report.Warnings, 31 | PluginWarnings: []protolock.Warning{}, 32 | }) 33 | if err != nil { 34 | return nil, err 35 | } 36 | 37 | // collect plugin warnings and errors as they are returned from plugins 38 | pluginWarningsChan := make(chan []protolock.Warning) 39 | pluginsDone := make(chan struct{}) 40 | pluginErrsChan := make(chan error) 41 | var allPluginErrors []error 42 | go func() { 43 | for { 44 | select { 45 | case <-pluginsDone: 46 | return 47 | 48 | case err := <-pluginErrsChan: 49 | if err != nil { 50 | allPluginErrors = append(allPluginErrors, err) 51 | } 52 | 53 | case warnings := <-pluginWarningsChan: 54 | for _, warning := range warnings { 55 | report.Warnings = append(report.Warnings, warning) 56 | } 57 | } 58 | } 59 | }() 60 | 61 | wg := &sync.WaitGroup{} 62 | plugins := strings.Split(pluginList, ",") 63 | for _, name := range plugins { 64 | wg.Add(1) 65 | 66 | // copy input data to be passed in to and processed by each plugin 67 | pluginInputData := bytes.NewReader(inputData.Bytes()) 68 | 69 | // run all provided plugins in parallel, each recieving the same current 70 | // and updated Protolock structs from the `protolock status` call 71 | go func(name string) { 72 | defer wg.Done() 73 | // output is populated either by the execution of an Extism plugin or a native binary 74 | var output []byte 75 | name = strings.TrimSpace(name) 76 | path := name 77 | 78 | if debug { 79 | fmt.Println(logPrefix, name, "running plugin") 80 | } 81 | 82 | if strings.HasSuffix(name, ".wasm") { 83 | // do extism call 84 | manifest := extism.Manifest{ 85 | Wasm: []extism.Wasm{extism.WasmFile{Path: name}}, 86 | // TODO: consider enabling external configuration to add hosts and paths 87 | // AllowedHosts: []string{}, 88 | // AllowedPaths: map[string]string{}, 89 | } 90 | 91 | plugin, err := extism.NewPlugin(context.Background(), manifest, extism.PluginConfig{EnableWasi: true}, nil) 92 | if err != nil { 93 | fmt.Println(logPrefix, name, "failed to create extism plugin:", err) 94 | return 95 | } 96 | 97 | var exitCode uint32 98 | exitCode, output, err = plugin.Call("status", inputData.Bytes()) 99 | if err != nil { 100 | fmt.Println(logPrefix, name, "plugin exec error: ", err, "code:", exitCode) 101 | pluginErrsChan <- wrapPluginErr(name, path, err, output) 102 | return 103 | } 104 | 105 | } else { 106 | path, err = exec.LookPath(name) 107 | if err != nil { 108 | if path == "" { 109 | path = name 110 | } 111 | fmt.Println(logPrefix, name, "plugin exec error:", err) 112 | return 113 | } 114 | 115 | // initialize the executable to be called from protolock using the 116 | // absolute path and copy of the input data 117 | plugin := &exec.Cmd{ 118 | Path: path, 119 | Stdin: pluginInputData, 120 | } 121 | 122 | // execute the plugin and capture the output 123 | output, err = plugin.CombinedOutput() 124 | if err != nil { 125 | pluginErrsChan <- wrapPluginErr(name, path, err, output) 126 | return 127 | } 128 | } 129 | 130 | pluginData := &extend.Data{} 131 | err = json.Unmarshal(output, pluginData) 132 | if err != nil { 133 | fmt.Println(logPrefix, name, "plugin data decode error:", err) 134 | // TODO: depending on the plugin, "output" could be quite 135 | // verbose, though may not warrant debug flag guard. 136 | if debug { 137 | fmt.Println( 138 | logPrefix, name, "plugin output:", string(output), 139 | ) 140 | } 141 | return 142 | } 143 | 144 | // gather all warnings from each plugin, and send to warning chan 145 | // collector as a slice to keep together 146 | if pluginData.PluginWarnings != nil { 147 | pluginWarningsChan <- pluginData.PluginWarnings 148 | } 149 | 150 | if pluginData.PluginErrorMessage != "" { 151 | pluginErrsChan <- wrapPluginErr( 152 | name, 153 | path, 154 | errors.New(pluginData.PluginErrorMessage), 155 | output, 156 | ) 157 | } 158 | }(name) 159 | } 160 | 161 | wg.Wait() 162 | pluginsDone <- struct{}{} 163 | 164 | if allPluginErrors != nil { 165 | var errorMsgs []string 166 | for _, pluginError := range allPluginErrors { 167 | errorMsgs = append(errorMsgs, pluginError.Error()) 168 | } 169 | 170 | return nil, fmt.Errorf( 171 | "accumulated plugin errors: \n%s", 172 | strings.Join(errorMsgs, "\n"), 173 | ) 174 | } 175 | 176 | return report, nil 177 | } 178 | 179 | func wrapPluginErr(name, path string, err error, output []byte) error { 180 | return fmt.Errorf( 181 | "%s (%s): %v\n%s", 182 | name, path, err, strings.ReplaceAll( 183 | string(output), 184 | protolock.ProtoSep, protolock.FileSep, 185 | ), 186 | ) 187 | } 188 | -------------------------------------------------------------------------------- /commit.go: -------------------------------------------------------------------------------- 1 | package protolock 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "os" 7 | ) 8 | 9 | // Commit will return an io.Reader with the lock representation data for caller to 10 | // use as needed. 11 | func Commit(cfg Config) (io.Reader, error) { 12 | if !cfg.LockFileExists() { 13 | fmt.Println(`no "proto.lock" file found, first run "init"`) 14 | os.Exit(1) 15 | } 16 | 17 | updated, err := getUpdatedLock(cfg) 18 | if err != nil { 19 | return nil, err 20 | } 21 | 22 | return readerFromProtolock(updated) 23 | } 24 | -------------------------------------------------------------------------------- /config.go: -------------------------------------------------------------------------------- 1 | package protolock 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | ) 7 | 8 | type Config struct { 9 | LockDir string 10 | ProtoRoot string 11 | Ignore string 12 | UpToDate bool 13 | Debug bool 14 | } 15 | 16 | func NewConfig( 17 | lockDir, protoRoot, ignores string, 18 | upToDate, debug bool, 19 | ) (*Config, error) { 20 | l, err := filepath.Abs(lockDir) 21 | if err != nil { 22 | return nil, err 23 | } 24 | p, err := filepath.Abs(protoRoot) 25 | if err != nil { 26 | return nil, err 27 | } 28 | 29 | return &Config{ 30 | LockDir: l, 31 | ProtoRoot: p, 32 | Ignore: ignores, 33 | UpToDate: upToDate, 34 | }, nil 35 | } 36 | 37 | func (cfg *Config) LockFileExists() bool { 38 | _, err := os.Stat(cfg.LockFilePath()) 39 | return err == nil && !os.IsNotExist(err) 40 | } 41 | 42 | func (cfg *Config) LockFilePath() string { 43 | return filepath.Join(cfg.LockDir, LockFileName) 44 | } 45 | -------------------------------------------------------------------------------- /extend/extend_test.go: -------------------------------------------------------------------------------- 1 | package extend 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/nilslice/protolock" 7 | ) 8 | 9 | const fakeData = "test error message" 10 | 11 | type fakePlugin struct{} 12 | 13 | func (p *fakePlugin) Init(fn PluginFunc) { 14 | data := &Data{ 15 | Current: protolock.Protolock{}, 16 | Updated: protolock.Protolock{}, 17 | PluginWarnings: nil, 18 | PluginErrorMessage: fakeData, 19 | } 20 | 21 | _ = fn(data) 22 | } 23 | 24 | func TestPluginInit(t *testing.T) { 25 | p := &fakePlugin{} 26 | p.Init(func(data *Data) *Data { 27 | if data.PluginErrorMessage != "test error message" { 28 | t.Logf("incorrect error message: %s", data.PluginErrorMessage) 29 | t.Fail() 30 | } 31 | 32 | if len(data.Current.Definitions) != 0 { 33 | t.Fail() 34 | } 35 | 36 | if len(data.Updated.Definitions) != 0 { 37 | t.Fail() 38 | } 39 | 40 | if data.PluginWarnings != nil { 41 | t.Fail() 42 | } 43 | 44 | return data 45 | }) 46 | } 47 | -------------------------------------------------------------------------------- /extend/plugin.go: -------------------------------------------------------------------------------- 1 | package extend 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "os" 9 | 10 | "github.com/nilslice/protolock" 11 | ) 12 | 13 | // Plugin is an interface that defines the protolock plugin specification. 14 | type Plugin interface { 15 | Init(PluginFunc) 16 | } 17 | 18 | // Data contains the current and updated Protolock structs created by the 19 | // `protolock` internal parser and deserializer, and a slice of Warning structs 20 | // for the plugin to append its own custom warnings. 21 | type Data struct { 22 | Current protolock.Protolock `json:"current,omitempty"` 23 | Updated protolock.Protolock `json:"updated,omitempty"` 24 | ProtolockWarnings []protolock.Warning `json:"protolock_warnings,omitempty"` 25 | PluginWarnings []protolock.Warning `json:"plugin_warnings,omitempty"` 26 | PluginErrorMessage string `json:"plugin_error_message,omitempty"` 27 | } 28 | 29 | // PluginFunc is a function which defines plugin behavior, and is provided a 30 | // pointer to Data. 31 | type PluginFunc func(d *Data) *Data 32 | 33 | type plugin struct { 34 | name string 35 | } 36 | 37 | // NewPlugin returns a plugin instance for a plugin to be initialized. 38 | func NewPlugin(name string) *plugin { 39 | return &plugin{ 40 | name: name, 41 | } 42 | } 43 | 44 | // Init is called by plugin code and is provided a PluginFunc from the caller 45 | // to handle the input Data (read from stdin). 46 | func (p *plugin) Init(fn PluginFunc) { 47 | // read from stdin to get serialized bytes 48 | input := &bytes.Buffer{} 49 | _, err := io.Copy(input, os.Stdin) 50 | if err != nil { 51 | p.wrapErrAndLog(err) 52 | return 53 | } 54 | 55 | // deserialize bytes into *Data 56 | inputData := &Data{} 57 | err = json.Unmarshal(input.Bytes(), inputData) 58 | if err != nil { 59 | p.wrapErrAndLog(err) 60 | return 61 | } 62 | 63 | // execute "fn" and pass it the *Data, where the plugin would read and 64 | // compare the current and updated Protolock values and append custom 65 | // Warnings for their own defined rules 66 | outputData := fn(inputData) 67 | outputData.Current = inputData.Current 68 | outputData.Updated = inputData.Updated 69 | 70 | // serialize *Data back and write to stdout 71 | p.wrapErrAndLog(json.NewEncoder(os.Stdout).Encode(outputData)) 72 | } 73 | 74 | func (p *plugin) wrapErrAndLog(err error) { 75 | if err != nil { 76 | fmt.Fprintf(os.Stdout, "[protolock:plugin] %s: %v", p.name, err) 77 | } 78 | } 79 | 80 | var _ Plugin = &plugin{} 81 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/nilslice/protolock 2 | 3 | go 1.21 4 | 5 | require ( 6 | github.com/emicklei/proto v1.13.2 7 | github.com/extism/go-sdk v1.0.0 8 | github.com/stretchr/testify v1.8.4 9 | ) 10 | 11 | require ( 12 | github.com/davecgh/go-spew v1.1.1 // indirect 13 | github.com/gobwas/glob v0.2.3 // indirect 14 | github.com/pmezard/go-difflib v1.0.0 // indirect 15 | github.com/tetratelabs/wazero v1.3.0 // indirect 16 | gopkg.in/yaml.v3 v3.0.1 // indirect 17 | ) 18 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 2 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/emicklei/proto v1.9.1 h1:MUgjFo5xlMwYv72TnF5xmmdKZ04u+dVbv6wdARv16D8= 4 | github.com/emicklei/proto v1.9.1/go.mod h1:rn1FgRS/FANiZdD2djyH7TMA9jdRDcYQ9IEN9yvjX0A= 5 | github.com/emicklei/proto v1.13.2 h1:z/etSFO3uyXeuEsVPzfl56WNgzcvIr42aQazXaQmFZY= 6 | github.com/emicklei/proto v1.13.2/go.mod h1:rn1FgRS/FANiZdD2djyH7TMA9jdRDcYQ9IEN9yvjX0A= 7 | github.com/extism/go-sdk v1.0.0 h1://UAyiQGok1ihrlzpkfF6UTY5TwJs6hKJBXnQ0sui20= 8 | github.com/extism/go-sdk v1.0.0/go.mod h1:xUfKSEQndAvHBc1Ohdre0e+UdnRzUpVfbA8QLcx4fbY= 9 | github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= 10 | github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= 11 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 12 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 13 | github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= 14 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 15 | github.com/tetratelabs/wazero v1.3.0 h1:nqw7zCldxE06B8zSZAY0ACrR9OH5QCcPwYmYlwtcwtE= 16 | github.com/tetratelabs/wazero v1.3.0/go.mod h1:wYx2gNRg8/WihJfSDxA1TIL8H+GkfLYm+bIfbblu9VQ= 17 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 18 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 19 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 20 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 21 | -------------------------------------------------------------------------------- /hints.go: -------------------------------------------------------------------------------- 1 | package protolock 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "strings" 7 | 8 | "github.com/emicklei/proto" 9 | ) 10 | 11 | const ( 12 | // CommentSkip tells the parse step to skip the comparable entity. 13 | CommentSkip = "@protolock:skip" 14 | 15 | // commentInternal is used for tests 16 | commentInternal = "@protolock:internal" 17 | ) 18 | 19 | var ( 20 | // ErrSkipEntry indicates that the CommentSkip hint was found. 21 | ErrSkipEntry = errors.New("protolock: skip entry hint encountered") 22 | 23 | // errInternalTest indicates that the internal test hint was found. 24 | errInternalTest = errors.New("protolock: internal hint encountered") 25 | ) 26 | 27 | func checkComments(v interface{}) []error { 28 | var errs []error 29 | switch v.(type) { 30 | case *proto.Enum: 31 | e := v.(*proto.Enum) 32 | errs = append(errs, hints(e.Comment)...) 33 | 34 | case *proto.Message: 35 | m := v.(*proto.Message) 36 | errs = append(errs, hints(m.Comment)...) 37 | 38 | case *proto.Service: 39 | s := v.(*proto.Service) 40 | errs = append(errs, hints(s.Comment)...) 41 | } 42 | 43 | return errs 44 | } 45 | 46 | func hints(c *proto.Comment) []error { 47 | if c == nil { 48 | return nil 49 | } 50 | 51 | var errs []error 52 | for _, line := range c.Lines { 53 | if strings.Contains(line, CommentSkip) { 54 | debugHint(c, CommentSkip) 55 | errs = append(errs, ErrSkipEntry) 56 | } 57 | 58 | if strings.Contains(line, commentInternal) { 59 | debugHint(c, commentInternal) 60 | errs = append(errs, errInternalTest) 61 | } 62 | } 63 | 64 | return errs 65 | } 66 | 67 | func debugHint(c *proto.Comment, hint string) { 68 | if debug { 69 | fmt.Println( 70 | "HINT:", hint, 71 | fmt.Sprintf( 72 | "%s:%d:%d", 73 | c.Position.Filename, c.Position.Line, c.Position.Column, 74 | ), 75 | ) 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /hints_test.go: -------------------------------------------------------------------------------- 1 | package protolock 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | const hintSkip = `syntax = "proto3"; 10 | package dataset; 11 | 12 | // @protolock:skip 13 | message Channel { 14 | reserved 6, 8 to 11; 15 | int64 id = 1; 16 | string name = 2; 17 | string description = 3; 18 | string foo = 4; 19 | int32 age = 5; 20 | } 21 | 22 | message NextRequest { 23 | // @protolock:skip 24 | enum DontTrack { 25 | NOTHING = 1; 26 | } 27 | } 28 | // this text before our hint shouldn't matter +(#*)//.~ @protolock:skip 29 | message PreviousRequest {} 30 | 31 | // @protolock:skip 32 | // @protolock:no-impl <- not a real hint, should pick up skip for ChannelChanger 33 | // @protolock:internal <- real internal hint, for testing 34 | service ChannelChanger { 35 | rpc Next(stream NextRequest) returns (Channel); 36 | rpc Previous(PreviousRequest) returns (stream Channel); 37 | } 38 | 39 | // @protolock:skip 40 | message Volume { 41 | float32 level = 1; 42 | } 43 | 44 | // @protolock:skip 45 | enum ShouldSkipEnum { 46 | ZERO = 0; 47 | } 48 | 49 | enum ShouldTrack { 50 | OK = 1; 51 | } 52 | 53 | service VolumeChanger { 54 | rpc Increase(stream IncreaseRequest) returns (Volume); 55 | rpc Decrease(DecreaseRequest) returns (Volume); 56 | } 57 | ` 58 | 59 | func TestHints(t *testing.T) { 60 | SetDebug(true) 61 | lock := parseTestProto(t, hintSkip) 62 | 63 | for _, def := range lock.Definitions { 64 | t.Run("skip:messages", func(t *testing.T) { 65 | assert.Len(t, def.Def.Messages, 1) 66 | assert.Equal(t, def.Def.Messages[0].Name, "NextRequest") 67 | }) 68 | t.Run("skip:services", func(t *testing.T) { 69 | assert.Len(t, def.Def.Services, 1) 70 | assert.Equal(t, def.Def.Services[0].Name, "VolumeChanger") 71 | }) 72 | t.Run("skip:enums", func(t *testing.T) { 73 | assert.Len(t, def.Def.Enums, 1) 74 | assert.Equal(t, def.Def.Enums[0].Name, "ShouldTrack") 75 | }) 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /init.go: -------------------------------------------------------------------------------- 1 | package protolock 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "os" 8 | "strings" 9 | ) 10 | 11 | const protoSuffix = ".proto" 12 | 13 | // Init will return an io.Reader with the lock representation data for caller to 14 | // use as needed. 15 | func Init(cfg Config) (io.Reader, error) { 16 | if cfg.LockFileExists() { 17 | fmt.Println(`a "proto.lock" file was already found, use "commit" to update`) 18 | os.Exit(1) 19 | } 20 | 21 | updated, err := getUpdatedLock(cfg) 22 | if err != nil { 23 | return nil, err 24 | } 25 | 26 | return readerFromProtolock(updated) 27 | } 28 | 29 | func readerFromProtolock(lock *Protolock) (io.Reader, error) { 30 | b, err := json.MarshalIndent(lock, "", " ") 31 | if err != nil { 32 | return nil, err 33 | } 34 | 35 | return strings.NewReader(string(b)), nil 36 | } 37 | -------------------------------------------------------------------------------- /order_test.go: -------------------------------------------------------------------------------- 1 | package protolock 2 | 3 | import ( 4 | "encoding/json" 5 | "os" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | const ignoreArg = "" 12 | 13 | func TestOrder(t *testing.T) { 14 | cfg, err := NewConfig(".", ".", ignoreArg, false, false) 15 | assert.NoError(t, err) 16 | 17 | // verify that the re-production of the same Protolock encoded as json 18 | // is equivalent to any previously encoded version of the same Protolock 19 | f, err := os.Open(cfg.LockFilePath()) 20 | assert.NoError(t, err) 21 | 22 | current, err := FromReader(f) 23 | assert.NoError(t, err) 24 | 25 | r, err := Commit(*cfg) 26 | assert.NoError(t, err) 27 | assert.NotNil(t, r) 28 | 29 | updated, err := FromReader(r) 30 | assert.NoError(t, err) 31 | 32 | assert.Equal(t, current, updated) 33 | 34 | a, err := json.Marshal(current) 35 | assert.NoError(t, err) 36 | b, err := json.Marshal(updated) 37 | assert.NoError(t, err) 38 | 39 | assert.Equal(t, a, b) 40 | } 41 | -------------------------------------------------------------------------------- /parse.go: -------------------------------------------------------------------------------- 1 | package protolock 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "os" 10 | "path/filepath" 11 | "strings" 12 | "sync" 13 | 14 | "github.com/emicklei/proto" 15 | ) 16 | 17 | const LockFileName = "proto.lock" 18 | 19 | type Protolock struct { 20 | Definitions []Definition `json:"definitions,omitempty"` 21 | } 22 | 23 | type Definition struct { 24 | Filepath Protopath `json:"protopath,omitempty"` 25 | Def Entry `json:"def,omitempty"` 26 | } 27 | 28 | type Entry struct { 29 | Enums []Enum `json:"enums,omitempty"` 30 | Messages []Message `json:"messages,omitempty"` 31 | Services []Service `json:"services,omitempty"` 32 | Imports []Import `json:"imports,omitempty"` 33 | Package Package `json:"package,omitempty"` 34 | Options []Option `json:"options,omitempty"` 35 | } 36 | 37 | type Import struct { 38 | Path string `json:"path,omitempty"` 39 | } 40 | 41 | type Package struct { 42 | Name string `json:"name,omitempty"` 43 | } 44 | 45 | type Option struct { 46 | Name string `json:"name,omitempty"` 47 | Value string `json:"value,omitempty"` 48 | Aggregated []Option `json:"aggregated,omitempty"` 49 | } 50 | 51 | type Message struct { 52 | Name string `json:"name,omitempty"` 53 | Fields []Field `json:"fields,omitempty"` 54 | Maps []Map `json:"maps,omitempty"` 55 | ReservedIDs []int `json:"reserved_ids,omitempty"` 56 | ReservedNames []string `json:"reserved_names,omitempty"` 57 | Filepath Protopath `json:"filepath,omitempty"` 58 | Messages []Message `json:"messages,omitempty"` 59 | Options []Option `json:"options,omitempty"` 60 | } 61 | 62 | type EnumField struct { 63 | Name string `json:"name,omitempty"` 64 | Integer int `json:"integer,omitempty"` 65 | Options []Option `json:"options,omitempty"` 66 | } 67 | 68 | type Enum struct { 69 | Name string `json:"name,omitempty"` 70 | EnumFields []EnumField `json:"enum_fields,omitempty"` 71 | ReservedIDs []int `json:"reserved_ids,omitempty"` 72 | ReservedNames []string `json:"reserved_names,omitempty"` 73 | AllowAlias bool `json:"allow_alias,omitempty"` 74 | Options []Option `json:"options,omitempty"` 75 | } 76 | 77 | type Map struct { 78 | KeyType string `json:"key_type,omitempty"` 79 | Field Field `json:"field,omitempty"` 80 | } 81 | 82 | type Field struct { 83 | ID int `json:"id,omitempty"` 84 | Name string `json:"name,omitempty"` 85 | Type string `json:"type,omitempty"` 86 | IsRepeated bool `json:"is_repeated,omitempty"` 87 | IsOptional bool `json:"optional,omitempty"` 88 | IsRequired bool `json:"required,omitempty"` 89 | Options []Option `json:"options,omitempty"` 90 | OneofParent string `json:"oneof_parent,omitempty"` 91 | } 92 | 93 | type Service struct { 94 | Name string `json:"name,omitempty"` 95 | RPCs []RPC `json:"rpcs,omitempty"` 96 | Filepath Protopath `json:"filepath,omitempty"` 97 | } 98 | 99 | type RPC struct { 100 | Name string `json:"name,omitempty"` 101 | InType string `json:"in_type,omitempty"` 102 | OutType string `json:"out_type,omitempty"` 103 | InStreamed bool `json:"in_streamed,omitempty"` 104 | OutStreamed bool `json:"out_streamed,omitempty"` 105 | Options []Option `json:"options,omitempty"` 106 | } 107 | 108 | type Report struct { 109 | Current Protolock `json:"current,omitempty"` 110 | Updated Protolock `json:"updated,omitempty"` 111 | Warnings []Warning `json:"warnings,omitempty"` 112 | } 113 | 114 | type Warning struct { 115 | Filepath Protopath `json:"filepath,omitempty"` 116 | Message string `json:"message,omitempty"` 117 | RuleName string `json:"rulename,omitempty"` 118 | } 119 | 120 | type ProtoFile struct { 121 | ProtoPath Protopath 122 | Entry Entry 123 | } 124 | 125 | var ( 126 | enums []Enum 127 | msgs []Message 128 | svcs []Service 129 | imps []Import 130 | pkg Package 131 | opts []Option 132 | 133 | ErrWarningsFound = errors.New("comparison found one or more warnings") 134 | ) 135 | 136 | func Parse(filename string, r io.Reader) (Entry, error) { 137 | parser := proto.NewParser(r) 138 | parser.Filename(filename) 139 | def, err := parser.Parse() 140 | if err != nil { 141 | return Entry{}, err 142 | } 143 | 144 | enums = []Enum{} 145 | msgs = []Message{} 146 | svcs = []Service{} 147 | imps = []Import{} 148 | opts = []Option{} 149 | 150 | proto.Walk( 151 | def, 152 | proto.WithEnum(withEnum), 153 | proto.WithService(withService), 154 | proto.WithMessage(withMessage), 155 | protoWithImport(withImport), 156 | protoWithPackage(withPackage), 157 | proto.WithOption(withOption), 158 | ) 159 | 160 | return Entry{ 161 | Enums: enums, 162 | Messages: msgs, 163 | Services: svcs, 164 | Imports: imps, 165 | Package: pkg, 166 | Options: opts, 167 | }, nil 168 | } 169 | 170 | func withEnum(e *proto.Enum) { 171 | errs := checkComments(e) 172 | if errs != nil { 173 | for _, err := range errs { 174 | switch err { 175 | case ErrSkipEntry: 176 | return 177 | } 178 | } 179 | } 180 | 181 | // handle nested enum within message, prepend message name to enum name 182 | if p, ok := e.Parent.(*proto.Message); ok { 183 | if p != nil { 184 | e.Name = fmt.Sprintf("%s.%s", p.Name, e.Name) 185 | } 186 | } 187 | 188 | enums = append(enums, parseEnum(e)) 189 | } 190 | 191 | func parseEnum(e *proto.Enum) Enum { 192 | enum := Enum{ 193 | Name: e.Name, 194 | } 195 | 196 | for _, v := range e.Elements { 197 | if ef, ok := v.(*proto.EnumField); ok { 198 | field := EnumField{ 199 | Name: ef.Name, 200 | Integer: ef.Integer, 201 | } 202 | for _, ee := range ef.Elements { 203 | if o, ok := ee.(*proto.Option); ok { 204 | field.Options = append(field.Options, Option{ 205 | Name: o.Name, 206 | Value: o.Constant.Source, 207 | }) 208 | } 209 | } 210 | enum.EnumFields = append(enum.EnumFields, field) 211 | } 212 | 213 | if o, ok := v.(*proto.Option); ok { 214 | enum.Options = append(enum.Options, Option{ 215 | Name: o.Name, 216 | Value: o.Constant.Source, 217 | }) 218 | } 219 | 220 | if r, ok := v.(*proto.Reserved); ok { 221 | // collect all reserved field IDs from the ranges 222 | for _, rng := range r.Ranges { 223 | // if range is only a single value, skip loop and 224 | // append single value to message's reserved slice 225 | if rng.From == rng.To { 226 | enum.ReservedIDs = append(enum.ReservedIDs, rng.From) 227 | continue 228 | } 229 | // add each item from the range inclusively 230 | for id := rng.From; id <= rng.To; id++ { 231 | enum.ReservedIDs = append(enum.ReservedIDs, id) 232 | } 233 | } 234 | 235 | // add all reserved field names 236 | enum.ReservedNames = append(enum.ReservedNames, r.FieldNames...) 237 | } 238 | } 239 | 240 | return enum 241 | } 242 | 243 | func withService(s *proto.Service) { 244 | errs := checkComments(s) 245 | if errs != nil { 246 | for _, err := range errs { 247 | switch err { 248 | case ErrSkipEntry: 249 | return 250 | } 251 | } 252 | } 253 | 254 | svc := Service{ 255 | Name: s.Name, 256 | } 257 | 258 | for _, v := range s.Elements { 259 | if r, ok := v.(*proto.RPC); ok { 260 | svc.RPCs = append(svc.RPCs, RPC{ 261 | Name: r.Name, 262 | InType: r.RequestType, 263 | OutType: r.ReturnsType, 264 | InStreamed: r.StreamsRequest, 265 | OutStreamed: r.StreamsReturns, 266 | Options: parseOptions(r.Options), 267 | }) 268 | } 269 | } 270 | 271 | svcs = append(svcs, svc) 272 | } 273 | 274 | func withMessage(m *proto.Message) { 275 | errs := checkComments(m) 276 | if errs != nil { 277 | for _, err := range errs { 278 | switch err { 279 | case ErrSkipEntry: 280 | return 281 | } 282 | } 283 | } 284 | 285 | if _, ok := m.Parent.(*proto.Proto); !ok { 286 | return 287 | } 288 | 289 | msgs = append(msgs, parseMessage(m)) 290 | } 291 | 292 | func parseMessage(m *proto.Message) Message { 293 | msg := Message{ 294 | Name: m.Name, 295 | } 296 | 297 | for _, v := range m.Elements { 298 | 299 | if f, ok := v.(*proto.NormalField); ok { 300 | msg.Fields = append(msg.Fields, Field{ 301 | ID: f.Sequence, 302 | Name: f.Name, 303 | Type: f.Type, 304 | IsRepeated: f.Repeated, 305 | IsOptional: f.Optional, 306 | IsRequired: f.Required, 307 | Options: parseOptions(f.Options), 308 | }) 309 | } 310 | 311 | if mp, ok := v.(*proto.MapField); ok { 312 | f := mp.Field 313 | msg.Maps = append(msg.Maps, Map{ 314 | KeyType: mp.KeyType, 315 | Field: Field{ 316 | ID: f.Sequence, 317 | Name: f.Name, 318 | Type: f.Type, 319 | IsRepeated: false, 320 | Options: parseOptions(f.Options), 321 | }, 322 | }) 323 | } 324 | 325 | if oo, ok := v.(*proto.Oneof); ok { 326 | var fields []Field 327 | for _, el := range oo.Elements { 328 | if f, ok := el.(*proto.OneOfField); ok { 329 | fields = append(fields, Field{ 330 | ID: f.Sequence, 331 | Name: f.Name, 332 | Type: f.Type, 333 | IsRepeated: false, 334 | Options: parseOptions(f.Options), 335 | OneofParent: oo.Name, 336 | }) 337 | } 338 | } 339 | msg.Fields = append(msg.Fields, fields...) 340 | } 341 | 342 | if r, ok := v.(*proto.Reserved); ok { 343 | // collect all reserved field IDs from the ranges 344 | for _, rng := range r.Ranges { 345 | // if range is only a single value, skip loop and 346 | // append single value to message's reserved slice 347 | if rng.From == rng.To { 348 | msg.ReservedIDs = append(msg.ReservedIDs, rng.From) 349 | continue 350 | } 351 | // add each item from the range inclusively 352 | for id := rng.From; id <= rng.To; id++ { 353 | msg.ReservedIDs = append(msg.ReservedIDs, id) 354 | } 355 | } 356 | 357 | // add all reserved field names 358 | msg.ReservedNames = append(msg.ReservedNames, r.FieldNames...) 359 | } 360 | 361 | if o, ok := v.(*proto.Option); ok { 362 | msg.Options = append(msg.Options, parseOption(o)) 363 | } 364 | 365 | if m, ok := v.(*proto.Message); ok { 366 | msg.Messages = append(msg.Messages, parseMessage(m)) 367 | } 368 | } 369 | 370 | return msg 371 | } 372 | 373 | func withOption(o *proto.Option) { 374 | if _, ok := o.Parent.(*proto.Proto); !ok { 375 | return 376 | } 377 | opts = append(opts, parseOption(o)) 378 | } 379 | 380 | func parseOptions(opts []*proto.Option) []Option { 381 | var msgOpts []Option 382 | for _, o := range opts { 383 | msgOpts = append(msgOpts, parseOption(o)) 384 | } 385 | return msgOpts 386 | } 387 | 388 | func parseOption(o *proto.Option) Option { 389 | return recurseLiteral(o.Name, &o.Constant) 390 | } 391 | 392 | func recurseLiteral(name string, lit *proto.Literal) Option { 393 | 394 | if lit.OrderedMap != nil { 395 | var opts []Option 396 | for _, l := range lit.OrderedMap { 397 | opts = append(opts, recurseLiteral(l.Name, l.Literal)) 398 | } 399 | return Option{ 400 | Name: name, 401 | Aggregated: opts, 402 | } 403 | } 404 | 405 | if lit.Array != nil { 406 | var opts []Option 407 | for _, l := range lit.Array { 408 | opts = append(opts, recurseLiteral("", l)) 409 | } 410 | return Option{ 411 | Name: name, 412 | Aggregated: opts, 413 | } 414 | } 415 | 416 | return Option{ 417 | Name: name, 418 | Value: lit.Source, 419 | } 420 | } 421 | 422 | func protoWithImport(apply func(p *proto.Import)) proto.Handler { 423 | return func(v proto.Visitee) { 424 | if s, ok := v.(*proto.Import); ok { 425 | apply(s) 426 | } 427 | } 428 | } 429 | 430 | func withImport(im *proto.Import) { 431 | imp := Import{ 432 | Path: im.Filename, 433 | } 434 | imps = append(imps, imp) 435 | } 436 | 437 | func protoWithPackage(apply func(p *proto.Package)) proto.Handler { 438 | return func(v proto.Visitee) { 439 | if s, ok := v.(*proto.Package); ok { 440 | apply(s) 441 | } 442 | } 443 | } 444 | 445 | func withPackage(im *proto.Package) { 446 | pkg = Package{ 447 | Name: im.Name, 448 | } 449 | } 450 | 451 | // openLockFile opens and returns the lock file on disk for reading. 452 | func openLockFile(cfg Config) (io.ReadCloser, error) { 453 | f, err := os.Open(cfg.LockFilePath()) 454 | if err != nil { 455 | return nil, err 456 | } 457 | 458 | return f, nil 459 | } 460 | 461 | // FromReader unmarshals a proto.lock file into a Protolock struct. 462 | func FromReader(r io.Reader) (Protolock, error) { 463 | buf := bytes.Buffer{} 464 | _, err := io.Copy(&buf, r) 465 | if err != nil { 466 | return Protolock{}, err 467 | } 468 | 469 | var lock Protolock 470 | err = json.Unmarshal(buf.Bytes(), &lock) 471 | if err != nil { 472 | return Protolock{}, err 473 | } 474 | 475 | return lock, nil 476 | } 477 | 478 | // Compare returns a Report struct and an error which indicates that there is 479 | // one or more warnings to report to the caller. If no error is returned, the 480 | // Report can be ignored. 481 | func Compare(current, update Protolock) (*Report, error) { 482 | var warnings []Warning 483 | var wg sync.WaitGroup 484 | report := &Report{ 485 | Current: current, 486 | Updated: update, 487 | } 488 | for _, rule := range Rules { 489 | wg.Add(1) 490 | go func() { 491 | if debug { 492 | beginRuleDebug(rule.Name) 493 | } 494 | _warnings, _ := rule.Func(current, update) 495 | for i := range _warnings { 496 | _warnings[i].RuleName = rule.Name 497 | } 498 | if debug { 499 | concludeRuleDebug(rule.Name, _warnings) 500 | } 501 | 502 | warnings = append(warnings, _warnings...) 503 | wg.Done() 504 | }() 505 | wg.Wait() 506 | } 507 | report.Warnings = warnings 508 | 509 | if len(report.Warnings) != 0 { 510 | return report, ErrWarningsFound 511 | } 512 | 513 | return report, nil 514 | } 515 | 516 | // getProtoFiles finds recursively all .proto files to be processed. 517 | func getProtoFiles(root string, ignores string) ([]string, error) { 518 | protoFiles := []string{} 519 | 520 | err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error { 521 | if err != nil { 522 | return err 523 | } 524 | 525 | // if not a .proto file, do not attempt to parse. 526 | if !strings.HasSuffix(info.Name(), protoSuffix) { 527 | return nil 528 | } 529 | 530 | // skip to next if is a directory 531 | if info.IsDir() { 532 | return nil 533 | } 534 | 535 | // skip if path is within an ignored path 536 | if ignores != "" { 537 | for _, ignore := range strings.Split(ignores, ",") { 538 | rel, err := filepath.Rel(filepath.Join(root, ignore), path) 539 | if err != nil { 540 | return nil 541 | } 542 | 543 | if !strings.HasPrefix(rel, ".."+string(os.PathSeparator)) { 544 | return nil 545 | } 546 | } 547 | } 548 | 549 | protoFiles = append(protoFiles, path) 550 | 551 | return nil 552 | }) 553 | if err != nil { 554 | return nil, err 555 | } 556 | 557 | return protoFiles, nil 558 | } 559 | 560 | // getUpdatedLock finds all .proto files recursively in tree, parse each file 561 | // and accumulate all definitions into an updated Protolock. 562 | func getUpdatedLock(cfg Config) (*Protolock, error) { 563 | // files is a slice of struct `ProtoFile` to be joined into the proto.lock file. 564 | var files []ProtoFile 565 | 566 | root, err := filepath.Abs(cfg.ProtoRoot) 567 | if err != nil { 568 | return nil, err 569 | } 570 | 571 | protoFiles, err := getProtoFiles(root, cfg.Ignore) 572 | if err != nil { 573 | return nil, err 574 | } 575 | 576 | for _, path := range protoFiles { 577 | f, err := os.Open(path) 578 | if err != nil { 579 | return nil, err 580 | } 581 | 582 | // Have the parser report the file path 583 | friendlyPath := path 584 | cwd, err := os.Getwd() 585 | if err == nil { 586 | relpath, err := filepath.Rel(cwd, path) 587 | if err == nil { 588 | friendlyPath = relpath 589 | } 590 | } 591 | entry, err := Parse(friendlyPath, f) 592 | if err != nil { 593 | printIfErr(f.Close()) 594 | return nil, err 595 | } 596 | 597 | localPath := strings.TrimPrefix(path, root) 598 | localPath = strings.TrimPrefix(localPath, string(filepath.Separator)) 599 | protoFile := ProtoFile{ 600 | ProtoPath: ProtoPath(Protopath(localPath)), 601 | Entry: entry, 602 | } 603 | files = append(files, protoFile) 604 | 605 | // manually close the file to prevent `too many open files` error 606 | printIfErr(f.Close()) 607 | } 608 | 609 | // add all the definitions from the updated set of protos to a Protolock 610 | // used for analysis and comparison against the current Protolock, saved 611 | // as the proto.lock file in the current directory 612 | var updated Protolock 613 | for _, file := range files { 614 | updated.Definitions = append(updated.Definitions, Definition{ 615 | Filepath: file.ProtoPath, 616 | Def: file.Entry, 617 | }) 618 | } 619 | 620 | return &updated, nil 621 | } 622 | 623 | func printIfErr(err error) { 624 | if err != nil { 625 | fmt.Printf("protolock: %v\n", err) 626 | } 627 | } 628 | -------------------------------------------------------------------------------- /parse_test.go: -------------------------------------------------------------------------------- 1 | package protolock 2 | 3 | import ( 4 | "path/filepath" 5 | "strings" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | const protoWithImports = ` 13 | syntax = "proto3"; 14 | 15 | import "testdata/test.proto"; 16 | 17 | package test; 18 | 19 | message Channel { 20 | int64 id = 1; 21 | string name = 2; 22 | string description = 3; 23 | } 24 | ` 25 | 26 | const protoWithPackages = ` 27 | syntax = "proto3"; 28 | 29 | import "testdata/test.proto"; 30 | 31 | package test; 32 | 33 | message Channel { 34 | int64 id = 1; 35 | string name = 2; 36 | string description = 3; 37 | } 38 | ` 39 | const protoWithMessageOptions = ` 40 | syntax = "proto3"; 41 | 42 | package test; 43 | 44 | message Channel { 45 | option (ext.persisted) = true; 46 | int64 id = 1; 47 | string name = 2; 48 | string description = 3; 49 | } 50 | ` 51 | 52 | const protoWithNestedMessageOptions = ` 53 | syntax = "proto3"; 54 | 55 | package test; 56 | 57 | message Channel { 58 | option (ext.persisted) = { opt1: true opt2: false }; 59 | int64 id = 1; 60 | string name = 2; 61 | string description = 3; 62 | } 63 | ` 64 | 65 | const protoWithFieldOptions = ` 66 | syntax = "proto3"; 67 | 68 | package test; 69 | 70 | message Channel { 71 | int64 id = 1; 72 | string name = 2 [(personal) = true, (owner) = 'test']; 73 | string description = 3; 74 | map attributes = 4 [(personal) = true]; 75 | } 76 | ` 77 | 78 | const protoWithNestedFieldOptions = ` 79 | syntax = "proto3"; 80 | 81 | package test; 82 | 83 | message Channel { 84 | int64 id = 1; 85 | string name = 2; 86 | string description = 3; 87 | map attributes = 4; 88 | string address = 5 [(custom_options).personal = true, (custom_options).internal = false]; 89 | } 90 | ` 91 | 92 | const protoWithNestedFieldOptionsAggregated = ` 93 | syntax = "proto3"; 94 | 95 | package test; 96 | 97 | message Channel { 98 | int64 id = 1; 99 | string name = 2; 100 | string description = 3 [(custom_options_commas) = { personal: true, internal: false, owner: "some owner" }]; 101 | map attributes = 4; 102 | string address = 5 [(custom_options) = { personal: true internal: false owner: "some owner" }]; 103 | float array = 6 [(validate.rules).floats = {in: [4.56, 7.89]}]; 104 | float map = 7 [(validate.rules).keys = {map: { a: [4.56, 7.89], c: d}}]; 105 | } 106 | ` 107 | 108 | const protoWithEnumFieldOptions = ` 109 | syntax = "proto3"; 110 | 111 | package test; 112 | 113 | enum TestEnumOption { 114 | reserved 2; 115 | option allow_alias = true; 116 | FIRST = 0; 117 | SECOND = 1; 118 | SEGUNDO = 1 [(my_enum_value_option) = 321]; 119 | } 120 | ` 121 | 122 | const protoWithSingleQuoteReservedNames = ` 123 | syntax = "proto3"; 124 | 125 | package test; 126 | 127 | message Channel { 128 | reserved 'thing', 'another'; 129 | reserved "more", 'mixed'; 130 | int64 id = 1; 131 | string name = 2; 132 | string description = 3; 133 | } 134 | ` 135 | 136 | const protoWithRequiredAndOptionalFields = ` 137 | syntax = "proto2"; 138 | 139 | package test; 140 | 141 | message Channel { 142 | required int64 id = 1; 143 | optional string name = 2; 144 | } 145 | ` 146 | 147 | const protoWithRpcOptions = ` 148 | syntax = "proto3"; 149 | 150 | service TestService { 151 | rpc TestRpc (TestRequest) returns (TestResponse) { 152 | option (test_option) = "option_value"; 153 | option (test_option_2) = "option_value_2"; 154 | } 155 | } 156 | ` 157 | 158 | const protoWithEntryOptions = ` 159 | syntax = "proto3"; 160 | 161 | package test; 162 | 163 | option java_multiple_files = true; 164 | option java_package = "test.java.package"; 165 | option java_outer_classname = "TestClass"; 166 | ` 167 | 168 | const protoWithNoEntryOptions = ` 169 | syntax = "proto3"; 170 | 171 | package test; 172 | ` 173 | 174 | const protoWithEnumOptions = ` 175 | syntax = "proto3"; 176 | 177 | package test; 178 | 179 | enum TestEnumOption { 180 | reserved 2; 181 | option (allow_alias) = true; 182 | option (custom_enum_option) = 123; 183 | FIRST = 0; 184 | SECOND = 1; 185 | SEGUNDO = 1 [(my_enum_value_option) = 321]; 186 | } 187 | 188 | message TestNestedEnumOption { 189 | option (message_option) = 123; 190 | enum NestedEnum { 191 | option (enum_option) = 456; 192 | FIRST = 0; 193 | SECOND = 1; 194 | THIRD = 2; 195 | } 196 | } 197 | ` 198 | 199 | const protoWithOneof = ` 200 | syntax = "proto3"; 201 | 202 | import "testdata/test.proto"; 203 | 204 | package test; 205 | 206 | message Channel { 207 | oneof test_oneof { 208 | int64 id = 1; 209 | } 210 | string name = 2; 211 | string description = 3; 212 | } 213 | ` 214 | 215 | const protoWithCStyleInlineComments = ` 216 | syntax = "proto3"; 217 | 218 | message Example { 219 | optional /* i'm a comment */ bool field = 1; 220 | } 221 | ` 222 | 223 | var gpfPath = filepath.Join("testdata", "getProtoFiles") 224 | 225 | func TestParseSingleQuoteReservedNames(t *testing.T) { 226 | r := strings.NewReader(protoWithSingleQuoteReservedNames) 227 | 228 | entry, err := Parse("test:protoWithSingleQuoteReservedNames", r) 229 | assert.NoError(t, err) 230 | 231 | assert.Len(t, entry.Messages[0].ReservedNames, 4) 232 | assert.EqualValues(t, 233 | []string{"thing", "another", "more", "mixed"}, 234 | entry.Messages[0].ReservedNames, 235 | ) 236 | } 237 | 238 | func TestParseRequiredAndOptionalFields(t *testing.T) { 239 | r := strings.NewReader(protoWithRequiredAndOptionalFields) 240 | 241 | entry, err := Parse("test:protoWithRequiredAndOptionalFields", r) 242 | assert.NoError(t, err) 243 | 244 | assert.True(t, entry.Messages[0].Fields[0].IsRequired) 245 | assert.True(t, entry.Messages[0].Fields[1].IsOptional) 246 | } 247 | 248 | func TestParseIncludingImports(t *testing.T) { 249 | r := strings.NewReader(protoWithImports) 250 | 251 | entry, err := Parse("test:protoWithImports", r) 252 | assert.NoError(t, err) 253 | 254 | assert.Equal(t, "testdata/test.proto", entry.Imports[0].Path) 255 | } 256 | 257 | func TestParseIncludingPackage(t *testing.T) { 258 | r := strings.NewReader(protoWithPackages) 259 | 260 | entry, err := Parse("test:protoWithPackages", r) 261 | assert.NoError(t, err) 262 | 263 | assert.Equal(t, "test", entry.Package.Name) 264 | } 265 | 266 | func TestParseIncludingMessageOptions(t *testing.T) { 267 | r := strings.NewReader(protoWithMessageOptions) 268 | 269 | entry, err := Parse("test:protoWithMessageOptions", r) 270 | assert.NoError(t, err) 271 | 272 | assert.Equal(t, "(ext.persisted)", entry.Messages[0].Options[0].Name) 273 | assert.Equal(t, "true", entry.Messages[0].Options[0].Value) 274 | } 275 | 276 | func TestParseIncludingNestedMessageOptions(t *testing.T) { 277 | r := strings.NewReader(protoWithNestedMessageOptions) 278 | 279 | entry, err := Parse("test:protoWithNestedMessageOptions", r) 280 | assert.NoError(t, err) 281 | 282 | assert.Equal(t, "(ext.persisted)", entry.Messages[0].Options[0].Name) 283 | assert.Empty(t, entry.Messages[0].Options[0].Value) 284 | assert.Len(t, entry.Messages[0].Options[0].Aggregated, 2) 285 | assert.Equal(t, "opt1", entry.Messages[0].Options[0].Aggregated[0].Name) 286 | assert.Equal(t, "true", entry.Messages[0].Options[0].Aggregated[0].Value) 287 | assert.Equal(t, "opt2", entry.Messages[0].Options[0].Aggregated[1].Name) 288 | assert.Equal(t, "false", entry.Messages[0].Options[0].Aggregated[1].Value) 289 | } 290 | 291 | func TestParseIncludingFieldOptions(t *testing.T) { 292 | r := strings.NewReader(protoWithFieldOptions) 293 | 294 | entry, err := Parse("test:protoWithFieldOptions", r) 295 | assert.NoError(t, err) 296 | 297 | assert.Equal(t, "(personal)", entry.Messages[0].Fields[1].Options[0].Name) 298 | assert.Equal(t, "true", entry.Messages[0].Fields[1].Options[0].Value) 299 | assert.Equal(t, "(owner)", entry.Messages[0].Fields[1].Options[1].Name) 300 | assert.Equal(t, "test", entry.Messages[0].Fields[1].Options[1].Value) 301 | assert.Len(t, entry.Messages[0].Maps, 1) 302 | assert.Equal(t, "string", entry.Messages[0].Maps[0].KeyType) 303 | assert.Equal(t, "attributes", entry.Messages[0].Maps[0].Field.Name) 304 | assert.Len(t, entry.Messages[0].Maps[0].Field.Options, 1) 305 | assert.Equal(t, "(personal)", entry.Messages[0].Maps[0].Field.Options[0].Name) 306 | assert.Equal(t, "true", entry.Messages[0].Maps[0].Field.Options[0].Value) 307 | } 308 | 309 | func TestParseIncludingNestedFieldOptions(t *testing.T) { 310 | r := strings.NewReader(protoWithNestedFieldOptions) 311 | 312 | entry, err := Parse("test:protoWithNestedFieldOptions", r) 313 | assert.NoError(t, err) 314 | 315 | assert.Len(t, entry.Messages[0].Fields[3].Options, 2) 316 | assert.Equal(t, "(custom_options).personal", entry.Messages[0].Fields[3].Options[0].Name) 317 | assert.Equal(t, "true", entry.Messages[0].Fields[3].Options[0].Value) 318 | assert.Equal(t, "(custom_options).internal", entry.Messages[0].Fields[3].Options[1].Name) 319 | assert.Equal(t, "false", entry.Messages[0].Fields[3].Options[1].Value) 320 | } 321 | 322 | func TestParseIncludingNestedFieldOptionsAggregated(t *testing.T) { 323 | r := strings.NewReader(protoWithNestedFieldOptionsAggregated) 324 | 325 | entry, err := Parse("test:protoWithNestedFieldOptionsAggregated", r) 326 | assert.NoError(t, err) 327 | 328 | assert.Len(t, entry.Messages[0].Fields[2].Options, 1) 329 | assert.Equal(t, "(custom_options_commas)", entry.Messages[0].Fields[2].Options[0].Name) 330 | assert.Equal(t, "personal", entry.Messages[0].Fields[2].Options[0].Aggregated[0].Name) 331 | assert.Equal(t, "true", entry.Messages[0].Fields[2].Options[0].Aggregated[0].Value) 332 | assert.Equal(t, "internal", entry.Messages[0].Fields[2].Options[0].Aggregated[1].Name) 333 | assert.Equal(t, "false", entry.Messages[0].Fields[2].Options[0].Aggregated[1].Value) 334 | assert.Equal(t, "owner", entry.Messages[0].Fields[2].Options[0].Aggregated[2].Name) 335 | assert.Equal(t, "some owner", entry.Messages[0].Fields[2].Options[0].Aggregated[2].Value) 336 | assert.Len(t, entry.Messages[0].Fields[3].Options, 1) 337 | assert.Equal(t, "(custom_options)", entry.Messages[0].Fields[3].Options[0].Name) 338 | assert.Equal(t, "personal", entry.Messages[0].Fields[3].Options[0].Aggregated[0].Name) 339 | assert.Equal(t, "true", entry.Messages[0].Fields[3].Options[0].Aggregated[0].Value) 340 | assert.Equal(t, "internal", entry.Messages[0].Fields[3].Options[0].Aggregated[1].Name) 341 | assert.Equal(t, "false", entry.Messages[0].Fields[3].Options[0].Aggregated[1].Value) 342 | assert.Equal(t, "owner", entry.Messages[0].Fields[3].Options[0].Aggregated[2].Name) 343 | assert.Equal(t, "some owner", entry.Messages[0].Fields[3].Options[0].Aggregated[2].Value) 344 | assert.Equal(t, "in", entry.Messages[0].Fields[4].Options[0].Aggregated[0].Name) 345 | assert.Equal(t, "", entry.Messages[0].Fields[4].Options[0].Aggregated[0].Value) 346 | assert.Equal(t, "4.56", entry.Messages[0].Fields[4].Options[0].Aggregated[0].Aggregated[0].Value) 347 | assert.Equal(t, "7.89", entry.Messages[0].Fields[4].Options[0].Aggregated[0].Aggregated[1].Value) 348 | assert.Equal(t, "map", entry.Messages[0].Fields[5].Options[0].Aggregated[0].Name) 349 | assert.Equal(t, "", entry.Messages[0].Fields[5].Options[0].Aggregated[0].Value) 350 | assert.Equal(t, "a", entry.Messages[0].Fields[5].Options[0].Aggregated[0].Aggregated[0].Name) 351 | assert.Equal(t, "", entry.Messages[0].Fields[5].Options[0].Aggregated[0].Aggregated[0].Value) 352 | assert.Equal(t, "4.56", entry.Messages[0].Fields[5].Options[0].Aggregated[0].Aggregated[0].Aggregated[0].Value) 353 | assert.Equal(t, "7.89", entry.Messages[0].Fields[5].Options[0].Aggregated[0].Aggregated[0].Aggregated[1].Value) 354 | assert.Equal(t, "c", entry.Messages[0].Fields[5].Options[0].Aggregated[0].Aggregated[1].Name) 355 | assert.Equal(t, "d", entry.Messages[0].Fields[5].Options[0].Aggregated[0].Aggregated[1].Value) 356 | } 357 | 358 | func TestParseIncludingEnumFieldOptions(t *testing.T) { 359 | r := strings.NewReader(protoWithEnumFieldOptions) 360 | 361 | entry, err := Parse("test:protoWithEnumFieldOptions", r) 362 | assert.NoError(t, err) 363 | 364 | assert.Len(t, entry.Enums, 1) 365 | assert.Equal(t, "TestEnumOption", entry.Enums[0].Name) 366 | assert.Len(t, entry.Enums[0].EnumFields, 3) 367 | assert.Equal(t, "FIRST", entry.Enums[0].EnumFields[0].Name) 368 | assert.Equal(t, "SECOND", entry.Enums[0].EnumFields[1].Name) 369 | assert.Equal(t, "SEGUNDO", entry.Enums[0].EnumFields[2].Name) 370 | assert.Len(t, entry.Enums[0].EnumFields[2].Options, 1) 371 | assert.Equal(t, "(my_enum_value_option)", entry.Enums[0].EnumFields[2].Options[0].Name) 372 | assert.Equal(t, "321", entry.Enums[0].EnumFields[2].Options[0].Value) 373 | } 374 | 375 | func TestParseIncludingEnumOptions(t *testing.T) { 376 | r := strings.NewReader(protoWithEnumOptions) 377 | entry, err := Parse("test:protoWithEnumOptions", r) 378 | assert.NoError(t, err) 379 | assert.Len(t, entry.Enums, 2) 380 | assert.Equal(t, entry.Enums[0].Name, "TestEnumOption") 381 | assert.Len(t, entry.Enums[0].Options, 2) 382 | assert.Equal(t, entry.Enums[0].Options[0].Name, "(allow_alias)") 383 | assert.Equal(t, entry.Enums[0].Options[0].Value, "true") 384 | assert.Equal(t, entry.Enums[0].Options[1].Name, "(custom_enum_option)") 385 | assert.Equal(t, entry.Enums[0].Options[1].Value, "123") 386 | assert.Equal(t, entry.Enums[1].Name, "TestNestedEnumOption.NestedEnum") 387 | assert.Len(t, entry.Enums[1].Options, 1) 388 | assert.Equal(t, entry.Enums[1].Options[0].Name, "(enum_option)") 389 | assert.Equal(t, entry.Enums[1].Options[0].Value, "456") 390 | } 391 | 392 | func TestParseIncludingRpcOptions(t *testing.T) { 393 | r := strings.NewReader(protoWithRpcOptions) 394 | 395 | entry, err := Parse("test:protoWithRpcOptions", r) 396 | assert.NoError(t, err) 397 | 398 | assert.Len(t, entry.Services, 1) 399 | assert.Len(t, entry.Services[0].RPCs, 1) 400 | assert.Equal(t, "TestRpc", entry.Services[0].RPCs[0].Name) 401 | assert.Len(t, entry.Services[0].RPCs[0].Options, 2) 402 | assert.Equal(t, "(test_option)", entry.Services[0].RPCs[0].Options[0].Name) 403 | assert.Equal(t, "option_value", entry.Services[0].RPCs[0].Options[0].Value) 404 | assert.Equal(t, "(test_option_2)", entry.Services[0].RPCs[0].Options[1].Name) 405 | assert.Equal(t, "option_value_2", entry.Services[0].RPCs[0].Options[1].Value) 406 | } 407 | 408 | func TestParseWithEntryOptions(t *testing.T) { 409 | r := strings.NewReader(protoWithEntryOptions) 410 | 411 | entry, err := Parse("test:protoWithEntryOptions", r) 412 | assert.NoError(t, err) 413 | 414 | assert.Len(t, entry.Options, 3) 415 | assert.Equal(t, "java_multiple_files", entry.Options[0].Name) 416 | assert.Equal(t, "true", entry.Options[0].Value) 417 | assert.Equal(t, "java_package", entry.Options[1].Name) 418 | assert.Equal(t, "test.java.package", entry.Options[1].Value) 419 | assert.Equal(t, "java_outer_classname", entry.Options[2].Name) 420 | assert.Equal(t, "TestClass", entry.Options[2].Value) 421 | } 422 | 423 | func TestParseWithoutEntryOptions(t *testing.T) { 424 | r := strings.NewReader(protoWithNoEntryOptions) 425 | 426 | entry, err := Parse("test:protoWithNoEntryOptions", r) 427 | assert.NoError(t, err) 428 | 429 | assert.Len(t, entry.Options, 0) 430 | } 431 | 432 | func TestParseWithoutEntryOptionsWithRPCOptions(t *testing.T) { 433 | r := strings.NewReader(protoWithRpcOptions) 434 | 435 | entry, err := Parse("test:protoWithRpcOptions", r) 436 | assert.NoError(t, err) 437 | 438 | assert.Len(t, entry.Options, 0) 439 | assert.Len(t, entry.Services[0].RPCs[0].Options, 2) 440 | } 441 | 442 | func TestParseWithOneof(t *testing.T) { 443 | r := strings.NewReader(protoWithOneof) 444 | 445 | entry, err := Parse("test:protoWithOneof", r) 446 | assert.NoError(t, err) 447 | 448 | assert.Len(t, entry.Messages, 1) 449 | assert.Len(t, entry.Messages[0].Fields, 3) 450 | assert.Equal(t, "test_oneof", entry.Messages[0].Fields[0].OneofParent) 451 | assert.Equal(t, "", entry.Messages[0].Fields[1].OneofParent) 452 | assert.Equal(t, "", entry.Messages[0].Fields[2].OneofParent) 453 | } 454 | 455 | func TestGetProtoFilesFiltersDirectories(t *testing.T) { 456 | files, err := getProtoFiles(gpfPath, "") 457 | require.NoError(t, err) 458 | 459 | path := filepath.Join(gpfPath, "directory.proto") 460 | assert.NotContains(t, files, path) 461 | 462 | path = filepath.Join(gpfPath, "include", "include.proto") 463 | assert.Contains(t, files, path) 464 | } 465 | 466 | func TestGetProtoFilesFiltersNonProto(t *testing.T) { 467 | files, err := getProtoFiles(gpfPath, "") 468 | require.NoError(t, err) 469 | 470 | path := filepath.Join(gpfPath, "directory.proto", "test.non-proto") 471 | assert.NotContains(t, files, path) 472 | 473 | path = filepath.Join(gpfPath, "include", "include.proto") 474 | assert.Contains(t, files, path) 475 | } 476 | 477 | func TestGetProtoFilesIgnoresDirectories(t *testing.T) { 478 | files, err := getProtoFiles(gpfPath, "exclude") 479 | require.NoError(t, err) 480 | 481 | path := filepath.Join(gpfPath, "exclude", "test.proto") 482 | assert.NotContains(t, files, path) 483 | 484 | path = filepath.Join(gpfPath, "include", "include.proto") 485 | assert.Contains(t, files, path) 486 | } 487 | 488 | func TestGetProtoFilesIgnoresFiles(t *testing.T) { 489 | files, err := getProtoFiles(gpfPath, filepath.Join("include", "exclude.proto")) 490 | require.NoError(t, err) 491 | 492 | path := filepath.Join(gpfPath, "include", "exclude.proto") 493 | assert.NotContains(t, files, path) 494 | 495 | path = filepath.Join(gpfPath, "include", "include.proto") 496 | assert.Contains(t, files, path) 497 | } 498 | 499 | func TestGetProtoFilesIgnoresMultiple(t *testing.T) { 500 | paths := []string{"exclude", filepath.Join("include", "exclude.proto")} 501 | ignores := strings.Join(paths, ",") 502 | files, err := getProtoFiles(gpfPath, ignores) 503 | require.NoError(t, err) 504 | 505 | path := filepath.Join(gpfPath, "exclude", "test.proto") 506 | assert.NotContains(t, files, path) 507 | 508 | path = filepath.Join(gpfPath, "include", "exclude.proto") 509 | assert.NotContains(t, files, path) 510 | 511 | path = filepath.Join(gpfPath, "include", "include.proto") 512 | assert.Contains(t, files, path) 513 | } 514 | 515 | func TestCStyleInlineComments(t *testing.T) { 516 | r := strings.NewReader(protoWithCStyleInlineComments) 517 | 518 | entry, err := Parse("test:protoWithCStyleInlineComments", r) 519 | assert.NoError(t, err) 520 | assert.Len(t, entry.Messages, 1) 521 | 522 | example := entry.Messages[0] 523 | assert.Len(t, example.Fields, 1) 524 | assert.Equal(t, example.Name, "Example") 525 | 526 | // message Example { 527 | // optional /* i'm a comment */ bool field = 1; 528 | // } 529 | 530 | field := example.Fields[0] 531 | assert.Equal(t, field.Name, "field") 532 | assert.Equal(t, field.Type, "bool") 533 | assert.True(t, field.IsOptional) 534 | } 535 | -------------------------------------------------------------------------------- /plugin-samples/plugin-sample-error/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/nilslice/protolock" 5 | "github.com/nilslice/protolock/extend" 6 | ) 7 | 8 | func main() { 9 | plugin := extend.NewPlugin("sample-error") // "sample-error" is arbitrary name used to correlate error messages 10 | plugin.Init(func(data *extend.Data) *extend.Data { 11 | warnings := AddWarningsForExample(data.Current, data.Updated) 12 | data.PluginWarnings = append(data.PluginWarnings, warnings...) 13 | data.PluginErrorMessage = "some error" 14 | return data 15 | }) 16 | } 17 | 18 | func AddWarningsForExample(cur, upd protolock.Protolock) []protolock.Warning { 19 | return []protolock.Warning{ 20 | {Filepath: protolock.OSPath(upd.Definitions[0].Filepath), Message: "A sample warning!"}, 21 | {Filepath: protolock.OSPath(upd.Definitions[0].Filepath), Message: "Another sample warning.. ah!"}, 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /plugin-samples/plugin-sample-js/example.data.json: -------------------------------------------------------------------------------- 1 | { 2 | "current": { 3 | "definitions": [ 4 | { 5 | "protopath": "testdata:/:getProtoFiles:/:exclude:/:test.proto", 6 | "def": { 7 | "messages": [ 8 | { 9 | "name": "Test", 10 | "fields": [ 11 | { 12 | "id": 1, 13 | "name": "name", 14 | "type": "string" 15 | } 16 | ] 17 | } 18 | ] 19 | } 20 | }, 21 | { 22 | "protopath": "testdata:/:getProtoFiles:/:exclude.proto", 23 | "def": { 24 | "messages": [ 25 | { 26 | "name": "Exclude", 27 | "fields": [ 28 | { 29 | "id": 1, 30 | "name": "name", 31 | "type": "string" 32 | } 33 | ] 34 | } 35 | ] 36 | } 37 | }, 38 | { 39 | "protopath": "testdata:/:getProtoFiles:/:include:/:exclude.proto", 40 | "def": { 41 | "messages": [ 42 | { 43 | "name": "Exclude", 44 | "fields": [ 45 | { 46 | "id": 1, 47 | "name": "name", 48 | "type": "string" 49 | } 50 | ] 51 | } 52 | ] 53 | } 54 | }, 55 | { 56 | "protopath": "testdata:/:getProtoFiles:/:include:/:include.proto", 57 | "def": { 58 | "messages": [ 59 | { 60 | "name": "Include", 61 | "fields": [ 62 | { 63 | "id": 1, 64 | "name": "name", 65 | "type": "string" 66 | } 67 | ] 68 | } 69 | ] 70 | } 71 | }, 72 | { 73 | "protopath": "testdata:/:imports_options.proto", 74 | "def": { 75 | "messages": [ 76 | { 77 | "name": "Channel", 78 | "fields": [ 79 | { 80 | "id": 1, 81 | "name": "id", 82 | "type": "int64" 83 | }, 84 | { 85 | "id": 2, 86 | "name": "name", 87 | "type": "string" 88 | }, 89 | { 90 | "id": 3, 91 | "name": "description", 92 | "type": "string" 93 | } 94 | ] 95 | }, 96 | { 97 | "name": "Channel2", 98 | "fields": [ 99 | { 100 | "id": 1, 101 | "name": "id", 102 | "type": "int64" 103 | }, 104 | { 105 | "id": 2, 106 | "name": "name", 107 | "type": "string" 108 | }, 109 | { 110 | "id": 3, 111 | "name": "description", 112 | "type": "string" 113 | } 114 | ], 115 | "options": [ 116 | { 117 | "name": "(ext.persisted)", 118 | "value": "true" 119 | } 120 | ] 121 | } 122 | ], 123 | "imports": [ 124 | { 125 | "path": "testdata/test.proto" 126 | } 127 | ] 128 | } 129 | }, 130 | { 131 | "protopath": "testdata:/:test.proto", 132 | "def": { 133 | "enums": [ 134 | { 135 | "name": "TestEnum", 136 | "enum_fields": [ 137 | { 138 | "name": "FIRST", 139 | "integer": 0 140 | }, 141 | { 142 | "name": "SECOND", 143 | "integer": 1 144 | }, 145 | { 146 | "name": "SEGUNDO", 147 | "integer": 1 148 | } 149 | ], 150 | "reserved_ids": [ 151 | 2 152 | ] 153 | }, 154 | { 155 | "name": "ContainsEnum.NestedEnum", 156 | "enum_fields": [ 157 | { 158 | "name": "ABC", 159 | "integer": 1 160 | }, 161 | { 162 | "name": "DEF", 163 | "integer": 2 164 | } 165 | ], 166 | "reserved_ids": [ 167 | 101 168 | ], 169 | "reserved_names": [ 170 | "DEPTH" 171 | ] 172 | } 173 | ], 174 | "messages": [ 175 | { 176 | "name": "Channel", 177 | "fields": [ 178 | { 179 | "id": 1, 180 | "name": "id", 181 | "type": "int64" 182 | }, 183 | { 184 | "id": 2, 185 | "name": "name", 186 | "type": "string" 187 | }, 188 | { 189 | "id": 3, 190 | "name": "description", 191 | "type": "string" 192 | }, 193 | { 194 | "id": 4, 195 | "name": "foo", 196 | "type": "string" 197 | }, 198 | { 199 | "id": 5, 200 | "name": "age", 201 | "type": "int32" 202 | }, 203 | { 204 | "id": 44, 205 | "name": "msg", 206 | "type": "A" 207 | } 208 | ], 209 | "reserved_ids": [ 210 | 6, 211 | 8, 212 | 9, 213 | 10, 214 | 11 215 | ], 216 | "messages": [ 217 | { 218 | "name": "A", 219 | "fields": [ 220 | { 221 | "id": 1, 222 | "name": "id", 223 | "type": "int32" 224 | } 225 | ] 226 | } 227 | ] 228 | }, 229 | { 230 | "name": "Display", 231 | "fields": [ 232 | { 233 | "id": 1, 234 | "name": "width", 235 | "type": "int32" 236 | }, 237 | { 238 | "id": 2, 239 | "name": "height", 240 | "type": "int32" 241 | }, 242 | { 243 | "id": 44, 244 | "name": "msg", 245 | "type": "A" 246 | } 247 | ], 248 | "maps": [ 249 | { 250 | "key_type": "string", 251 | "field": { 252 | "id": 4, 253 | "name": "b_map", 254 | "type": "int32" 255 | } 256 | } 257 | ], 258 | "reserved_ids": [ 259 | 3 260 | ], 261 | "reserved_names": [ 262 | "a_map" 263 | ], 264 | "messages": [ 265 | { 266 | "name": "A", 267 | "fields": [ 268 | { 269 | "id": 1, 270 | "name": "id", 271 | "type": "int64" 272 | } 273 | ], 274 | "reserved_ids": [ 275 | 2 276 | ] 277 | } 278 | ] 279 | }, 280 | { 281 | "name": "ContainsEnum", 282 | "fields": [ 283 | { 284 | "id": 1, 285 | "name": "id", 286 | "type": "int32" 287 | }, 288 | { 289 | "id": 2, 290 | "name": "value", 291 | "type": "NestedEnum" 292 | } 293 | ] 294 | }, 295 | { 296 | "name": "PreviousRequest", 297 | "fields": [ 298 | { 299 | "id": 4, 300 | "name": "name", 301 | "type": "string" 302 | }, 303 | { 304 | "id": 9, 305 | "name": "is_active", 306 | "type": "bool" 307 | } 308 | ] 309 | } 310 | ], 311 | "services": [ 312 | { 313 | "name": "ChannelChanger", 314 | "rpcs": [ 315 | { 316 | "name": "Next", 317 | "in_type": "NextRequest", 318 | "out_type": "Channel", 319 | "in_streamed": true 320 | }, 321 | { 322 | "name": "Previous", 323 | "in_type": "PreviousRequest", 324 | "out_type": "Channel", 325 | "out_streamed": true 326 | } 327 | ] 328 | } 329 | ] 330 | } 331 | } 332 | ] 333 | }, 334 | "updated": { 335 | "definitions": [ 336 | { 337 | "protopath": "testdata:/:getProtoFiles:/:exclude:/:test.proto", 338 | "def": { 339 | "messages": [ 340 | { 341 | "name": "Test", 342 | "fields": [ 343 | { 344 | "id": 1, 345 | "name": "name", 346 | "type": "string" 347 | } 348 | ] 349 | } 350 | ] 351 | } 352 | }, 353 | { 354 | "protopath": "testdata:/:getProtoFiles:/:exclude.proto", 355 | "def": { 356 | "messages": [ 357 | { 358 | "name": "Exclude", 359 | "fields": [ 360 | { 361 | "id": 1, 362 | "name": "name", 363 | "type": "string" 364 | } 365 | ] 366 | } 367 | ] 368 | } 369 | }, 370 | { 371 | "protopath": "testdata:/:getProtoFiles:/:include:/:exclude.proto", 372 | "def": { 373 | "messages": [ 374 | { 375 | "name": "Exclude", 376 | "fields": [ 377 | { 378 | "id": 1, 379 | "name": "name", 380 | "type": "string" 381 | } 382 | ] 383 | } 384 | ] 385 | } 386 | }, 387 | { 388 | "protopath": "testdata:/:getProtoFiles:/:include:/:include.proto", 389 | "def": { 390 | "messages": [ 391 | { 392 | "name": "Include", 393 | "fields": [ 394 | { 395 | "id": 1, 396 | "name": "name", 397 | "type": "string" 398 | } 399 | ] 400 | } 401 | ] 402 | } 403 | }, 404 | { 405 | "protopath": "testdata:/:imports_options.proto", 406 | "def": { 407 | "messages": [ 408 | { 409 | "name": "Channel", 410 | "fields": [ 411 | { 412 | "id": 1, 413 | "name": "id", 414 | "type": "int64" 415 | }, 416 | { 417 | "id": 2, 418 | "name": "name", 419 | "type": "string" 420 | }, 421 | { 422 | "id": 3, 423 | "name": "description", 424 | "type": "string" 425 | } 426 | ] 427 | }, 428 | { 429 | "name": "Channel2", 430 | "fields": [ 431 | { 432 | "id": 1, 433 | "name": "id", 434 | "type": "int64" 435 | }, 436 | { 437 | "id": 2, 438 | "name": "name", 439 | "type": "string" 440 | }, 441 | { 442 | "id": 3, 443 | "name": "description", 444 | "type": "string" 445 | } 446 | ], 447 | "options": [ 448 | { 449 | "name": "(ext.persisted)", 450 | "value": "true" 451 | } 452 | ] 453 | } 454 | ], 455 | "imports": [ 456 | { 457 | "path": "testdata/test.proto" 458 | } 459 | ] 460 | } 461 | }, 462 | { 463 | "protopath": "testdata:/:test.proto", 464 | "def": { 465 | "enums": [ 466 | { 467 | "name": "TestEnum", 468 | "enum_fields": [ 469 | { 470 | "name": "FIRST", 471 | "integer": 0 472 | }, 473 | { 474 | "name": "SECOND", 475 | "integer": 1 476 | }, 477 | { 478 | "name": "SEGUNDO", 479 | "integer": 1 480 | } 481 | ], 482 | "reserved_ids": [ 483 | 2 484 | ] 485 | }, 486 | { 487 | "name": "ContainsEnum.NestedEnum", 488 | "enum_fields": [ 489 | { 490 | "name": "ABC", 491 | "integer": 1 492 | }, 493 | { 494 | "name": "DEF", 495 | "integer": 2 496 | } 497 | ], 498 | "reserved_ids": [ 499 | 101 500 | ], 501 | "reserved_names": [ 502 | "DEPTH" 503 | ] 504 | } 505 | ], 506 | "messages": [ 507 | { 508 | "name": "Channel", 509 | "fields": [ 510 | { 511 | "id": 1, 512 | "name": "id", 513 | "type": "int64" 514 | }, 515 | { 516 | "id": 2, 517 | "name": "name", 518 | "type": "string" 519 | }, 520 | { 521 | "id": 3, 522 | "name": "description", 523 | "type": "string" 524 | }, 525 | { 526 | "id": 4, 527 | "name": "foo", 528 | "type": "string" 529 | }, 530 | { 531 | "id": 5, 532 | "name": "age", 533 | "type": "int32" 534 | }, 535 | { 536 | "id": 44, 537 | "name": "msg", 538 | "type": "A" 539 | } 540 | ], 541 | "reserved_ids": [ 542 | 6, 543 | 8, 544 | 9, 545 | 10, 546 | 11 547 | ], 548 | "messages": [ 549 | { 550 | "name": "A", 551 | "fields": [ 552 | { 553 | "id": 1, 554 | "name": "id", 555 | "type": "int32" 556 | } 557 | ] 558 | } 559 | ] 560 | }, 561 | { 562 | "name": "Display", 563 | "fields": [ 564 | { 565 | "id": 1, 566 | "name": "width", 567 | "type": "int32" 568 | }, 569 | { 570 | "id": 2, 571 | "name": "height", 572 | "type": "int32" 573 | }, 574 | { 575 | "id": 44, 576 | "name": "msg", 577 | "type": "A" 578 | } 579 | ], 580 | "maps": [ 581 | { 582 | "key_type": "string", 583 | "field": { 584 | "id": 4, 585 | "name": "b_map", 586 | "type": "int32" 587 | } 588 | } 589 | ], 590 | "reserved_ids": [ 591 | 3 592 | ], 593 | "reserved_names": [ 594 | "a_map" 595 | ], 596 | "messages": [ 597 | { 598 | "name": "A", 599 | "fields": [ 600 | { 601 | "id": 1, 602 | "name": "id", 603 | "type": "int64" 604 | } 605 | ], 606 | "reserved_ids": [ 607 | 2 608 | ] 609 | } 610 | ] 611 | }, 612 | { 613 | "name": "ContainsEnum", 614 | "fields": [ 615 | { 616 | "id": 1, 617 | "name": "id", 618 | "type": "int32" 619 | }, 620 | { 621 | "id": 2, 622 | "name": "value", 623 | "type": "NestedEnum" 624 | } 625 | ] 626 | }, 627 | { 628 | "name": "PreviousRequest", 629 | "fields": [ 630 | { 631 | "id": 4, 632 | "name": "name", 633 | "type": "string" 634 | }, 635 | { 636 | "id": 9, 637 | "name": "is_active", 638 | "type": "bool" 639 | } 640 | ] 641 | } 642 | ], 643 | "services": [ 644 | { 645 | "name": "ChannelChanger", 646 | "rpcs": [ 647 | { 648 | "name": "Next", 649 | "in_type": "NextRequest", 650 | "out_type": "Channel", 651 | "in_streamed": true 652 | }, 653 | { 654 | "name": "Previous", 655 | "in_type": "PreviousRequest", 656 | "out_type": "Channel", 657 | "out_streamed": true 658 | } 659 | ] 660 | } 661 | ] 662 | } 663 | } 664 | ] 665 | } 666 | } -------------------------------------------------------------------------------- /plugin-samples/plugin-sample-js/main.js: -------------------------------------------------------------------------------- 1 | // your plugin receives JSON into its stdin, in the shape of the data object below 2 | let data = { 3 | current: {}, 4 | updated: {}, 5 | protolock_warnings: [{ 6 | filepath: "", 7 | message: "", 8 | name: "", 9 | }], 10 | plugin_warnings: [{ 11 | filepath: "", 12 | message: "", 13 | name: "", 14 | }], 15 | plugin_error_message: "", 16 | } 17 | 18 | process.stdin.setEncoding('utf8') 19 | 20 | function readStdinSync() { 21 | return new Promise((resolve, reject) => { 22 | process.stdin.resume() 23 | process.stdin.on('data', function (data) { 24 | process.stdin.pause() 25 | resolve(data) 26 | }) 27 | }) 28 | } 29 | 30 | async function main() { 31 | data = JSON.parse(await readStdinSync()) 32 | 33 | console.log(JSON.stringify(customRuleFunc(data))) // console.log writes to stdout 34 | } 35 | 36 | function customRuleFunc(data) { 37 | // compare proto primitives inside the data.current and data.updated objects 38 | let warnings = [ 39 | { 40 | filepath: "path/to/file.proto", 41 | message: "Something bad happened." 42 | }, 43 | { 44 | filepath: "path/to/another.proto", 45 | message: "Something else bad happened." 46 | } 47 | 48 | ] 49 | 50 | data.plugin_warnings = (data.plugin_warnings || []).concat(warnings) 51 | return data 52 | } 53 | 54 | main() -------------------------------------------------------------------------------- /plugin-samples/plugin-sample-js/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "plugin-sample-js", 3 | "version": "0.0.1", 4 | "description": "sample nodejs plugin to define custom protolock rules", 5 | "main": "main.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/nilslice/protolock.git" 12 | }, 13 | "author": "Steve Manuel", 14 | "license": "BSD-3-Clause", 15 | "bugs": { 16 | "url": "https://github.com/nilslice/protolock/issues" 17 | }, 18 | "homepage": "https://github.com/nilslice/protolock#readme" 19 | } 20 | -------------------------------------------------------------------------------- /plugin-samples/plugin-sample-wasm/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/nilslice/protolock/plugin-samples/plugin-sample-wasm 2 | 3 | go 1.21 4 | 5 | require ( 6 | github.com/emicklei/proto v1.9.1 // indirect 7 | github.com/extism/go-pdk v1.0.0 // indirect 8 | github.com/nilslice/protolock v0.17.0 // indirect 9 | ) 10 | -------------------------------------------------------------------------------- /plugin-samples/plugin-sample-wasm/go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 2 | github.com/emicklei/proto v1.9.1 h1:MUgjFo5xlMwYv72TnF5xmmdKZ04u+dVbv6wdARv16D8= 3 | github.com/emicklei/proto v1.9.1/go.mod h1:rn1FgRS/FANiZdD2djyH7TMA9jdRDcYQ9IEN9yvjX0A= 4 | github.com/extism/go-pdk v1.0.0 h1:/VlFLDnpYfooMl+VW94VHrbdruDyKkpa47yYJ7YcCAE= 5 | github.com/extism/go-pdk v1.0.0/go.mod h1:Gz+LIU/YCKnKXhgge8yo5Yu1F/lbv7KtKFkiCSzW/P4= 6 | github.com/nilslice/protolock v0.17.0 h1:sYvcukABl62tZX77H6NuV+jtlwTIfQbn0ln0ixTqr4A= 7 | github.com/nilslice/protolock v0.17.0/go.mod h1:DYFqop7QlHjmBCaJKfcVO1Mw5b8JejJZgMvmFng/N9Y= 8 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 9 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 10 | -------------------------------------------------------------------------------- /plugin-samples/plugin-sample-wasm/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | pdk "github.com/extism/go-pdk" 7 | "github.com/nilslice/protolock" 8 | "github.com/nilslice/protolock/extend" 9 | ) 10 | 11 | // an Extism plugin uses a 'PDK' to communicate data input and output from its host system, in 12 | // this case, the `protolock` command. 13 | 14 | // see https://extism.org and https://github.com/extism/extism for more information. 15 | 16 | // In order to satisfy the current usage, an Extism Protolock plugin must export a single function 17 | // "status" with the following signature: 18 | 19 | //export status 20 | func status() int32 { 21 | // rather than taking input from stdin, like native Protolock plugins, Extism plugins take data 22 | // from their host, using the `pdk.Input()` function, returning bytes from protolock. 23 | var data extend.Data 24 | err := json.Unmarshal(pdk.Input(), &data.Current) 25 | if err != nil { 26 | pdk.SetError(err) 27 | return 1 28 | } 29 | 30 | // with the `extend.Data` available, you would do some checks on the current and updated set of 31 | // `proto.lock` representations. Here we are adding a warning to demonstrate that the plugin 32 | // works with some known data output to verify. 33 | warning := protolock.Warning{ 34 | Filepath: "fake.proto", 35 | Message: "An Extism plugin ran and checked the status of the proto.lock files", 36 | RuleName: "RuleNameXYZ", 37 | } 38 | data.PluginWarnings = append(data.PluginWarnings, warning) 39 | 40 | b, err := json.Marshal(data) 41 | if err != nil { 42 | pdk.SetError(err) 43 | return 1 44 | } 45 | 46 | // tather than writing data to stdout, like native Protolock plugins, Extism plugins provide 47 | // data back to their host, using the `pdk.Output()` function, returning bytes to protolock. 48 | pdk.Output(b) 49 | 50 | // non-zero return code here will result in Extism detecting an error. 51 | return 0 52 | } 53 | 54 | // this Go code is compiled to WebAssembly, and current compilers expect some entrypoint, even if 55 | // this function isn't called. 56 | func main() {} 57 | -------------------------------------------------------------------------------- /plugin-samples/plugin-sample-wasm/status.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nilslice/protolock/ac27b429a112f822ea45a16082e20ac2b86f0c4b/plugin-samples/plugin-sample-wasm/status.wasm -------------------------------------------------------------------------------- /plugin-samples/plugin-sample/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/nilslice/protolock" 8 | "github.com/nilslice/protolock/extend" 9 | ) 10 | 11 | func main() { 12 | plugin := extend.NewPlugin("sample") // "sample" is arbitrary name used to correlate error messages 13 | plugin.Init(func(data *extend.Data) *extend.Data { 14 | // list all existing rules violated from warnings passed into plugin 15 | // from protolock & write to output file 16 | out, err := os.Create("violations.txt") 17 | if err != nil { 18 | return data 19 | } 20 | for _, w := range data.ProtolockWarnings { 21 | fmt.Fprintln( 22 | out, "Encountered changes in violation of:", w.RuleName, 23 | ) 24 | } 25 | 26 | warnings := AddWarningsForExample(data.Current, data.Updated) 27 | data.PluginWarnings = append(data.PluginWarnings, warnings...) 28 | return data 29 | }) 30 | } 31 | 32 | func AddWarningsForExample(cur, upd protolock.Protolock) []protolock.Warning { 33 | return []protolock.Warning{ 34 | { 35 | Filepath: protolock.OSPath(upd.Definitions[0].Filepath), 36 | Message: "A sample warning!", 37 | }, 38 | { 39 | Filepath: protolock.OSPath(upd.Definitions[0].Filepath), 40 | Message: "Another sample warning.. ah!", 41 | }, 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /proto.lock: -------------------------------------------------------------------------------- 1 | { 2 | "definitions": [ 3 | { 4 | "protopath": "testdata:/:getProtoFiles:/:exclude:/:test.proto", 5 | "def": { 6 | "messages": [ 7 | { 8 | "name": "Test", 9 | "fields": [ 10 | { 11 | "id": 1, 12 | "name": "name", 13 | "type": "string" 14 | } 15 | ] 16 | } 17 | ], 18 | "package": { 19 | "name": "exclude" 20 | } 21 | } 22 | }, 23 | { 24 | "protopath": "testdata:/:getProtoFiles:/:exclude.proto", 25 | "def": { 26 | "messages": [ 27 | { 28 | "name": "Exclude", 29 | "fields": [ 30 | { 31 | "id": 1, 32 | "name": "name", 33 | "type": "string" 34 | } 35 | ] 36 | } 37 | ], 38 | "package": { 39 | "name": "exclude" 40 | } 41 | } 42 | }, 43 | { 44 | "protopath": "testdata:/:getProtoFiles:/:include:/:exclude.proto", 45 | "def": { 46 | "messages": [ 47 | { 48 | "name": "Exclude", 49 | "fields": [ 50 | { 51 | "id": 1, 52 | "name": "name", 53 | "type": "string" 54 | } 55 | ] 56 | } 57 | ], 58 | "package": { 59 | "name": "exclude" 60 | } 61 | } 62 | }, 63 | { 64 | "protopath": "testdata:/:getProtoFiles:/:include:/:include.proto", 65 | "def": { 66 | "messages": [ 67 | { 68 | "name": "Include", 69 | "fields": [ 70 | { 71 | "id": 1, 72 | "name": "name", 73 | "type": "string" 74 | } 75 | ] 76 | } 77 | ], 78 | "package": { 79 | "name": "include" 80 | } 81 | } 82 | }, 83 | { 84 | "protopath": "testdata:/:imports_options.proto", 85 | "def": { 86 | "enums": [ 87 | { 88 | "name": "TestEnumOption", 89 | "enum_fields": [ 90 | { 91 | "name": "FIRST" 92 | }, 93 | { 94 | "name": "SECOND", 95 | "integer": 1 96 | }, 97 | { 98 | "name": "SEGUNDO", 99 | "integer": 3, 100 | "options": [ 101 | { 102 | "name": "(my_enum_value_option)", 103 | "value": "321" 104 | } 105 | ] 106 | } 107 | ], 108 | "reserved_ids": [ 109 | 2 110 | ], 111 | "options": [ 112 | { 113 | "name": "allow_alias", 114 | "value": "true" 115 | } 116 | ] 117 | } 118 | ], 119 | "messages": [ 120 | { 121 | "name": "Channel", 122 | "fields": [ 123 | { 124 | "id": 1, 125 | "name": "id", 126 | "type": "int64" 127 | }, 128 | { 129 | "id": 2, 130 | "name": "name", 131 | "type": "string" 132 | }, 133 | { 134 | "id": 3, 135 | "name": "description", 136 | "type": "string" 137 | } 138 | ], 139 | "options": [ 140 | { 141 | "name": "(ext.persisted)", 142 | "aggregated": [ 143 | { 144 | "name": "opt1", 145 | "value": "true" 146 | }, 147 | { 148 | "name": "opt2", 149 | "value": "false" 150 | } 151 | ] 152 | } 153 | ] 154 | }, 155 | { 156 | "name": "Channel2", 157 | "fields": [ 158 | { 159 | "id": 1, 160 | "name": "id", 161 | "type": "int64" 162 | }, 163 | { 164 | "id": 2, 165 | "name": "name", 166 | "type": "string", 167 | "options": [ 168 | { 169 | "name": "(personal)", 170 | "value": "true" 171 | }, 172 | { 173 | "name": "(owner)", 174 | "value": "test" 175 | } 176 | ] 177 | }, 178 | { 179 | "id": 3, 180 | "name": "description", 181 | "type": "string", 182 | "options": [ 183 | { 184 | "name": "(custom_options_commas)", 185 | "aggregated": [ 186 | { 187 | "name": "personal", 188 | "value": "true" 189 | }, 190 | { 191 | "name": "internal", 192 | "value": "false" 193 | }, 194 | { 195 | "name": "owner", 196 | "value": "some owner" 197 | } 198 | ] 199 | } 200 | ] 201 | }, 202 | { 203 | "id": 5, 204 | "name": "address", 205 | "type": "string", 206 | "options": [ 207 | { 208 | "name": "(custom_options)", 209 | "aggregated": [ 210 | { 211 | "name": "personal", 212 | "value": "true" 213 | }, 214 | { 215 | "name": "internal", 216 | "value": "false" 217 | }, 218 | { 219 | "name": "owner", 220 | "value": "some owner" 221 | }, 222 | { 223 | "name": "arr", 224 | "aggregated": [ 225 | { 226 | "value": "1.2" 227 | }, 228 | { 229 | "value": "3.4" 230 | } 231 | ] 232 | }, 233 | { 234 | "name": "map", 235 | "aggregated": [ 236 | { 237 | "name": "a", 238 | "value": "b" 239 | }, 240 | { 241 | "name": "c", 242 | "value": "d" 243 | } 244 | ] 245 | } 246 | ] 247 | } 248 | ] 249 | } 250 | ], 251 | "maps": [ 252 | { 253 | "key_type": "string", 254 | "field": { 255 | "id": 4, 256 | "name": "map", 257 | "type": "int32", 258 | "options": [ 259 | { 260 | "name": "(personal)", 261 | "value": "true" 262 | } 263 | ] 264 | } 265 | } 266 | ], 267 | "options": [ 268 | { 269 | "name": "(ext.persisted)", 270 | "value": "true" 271 | } 272 | ] 273 | }, 274 | { 275 | "name": "FieldOptions", 276 | "fields": [ 277 | { 278 | "id": 1, 279 | "name": "personal", 280 | "type": "bool" 281 | }, 282 | { 283 | "id": 2, 284 | "name": "internal", 285 | "type": "bool" 286 | }, 287 | { 288 | "id": 3, 289 | "name": "owner", 290 | "type": "string" 291 | } 292 | ] 293 | }, 294 | { 295 | "name": "google.protobuf.FieldOptions", 296 | "fields": [ 297 | { 298 | "id": 50000, 299 | "name": "custom_options", 300 | "type": "FieldOptions" 301 | } 302 | ] 303 | } 304 | ], 305 | "imports": [ 306 | { 307 | "path": "google/protobuf/descriptor.proto" 308 | }, 309 | { 310 | "path": "testdata/test.proto" 311 | } 312 | ], 313 | "package": { 314 | "name": "test" 315 | } 316 | } 317 | }, 318 | { 319 | "protopath": "testdata:/:test.proto", 320 | "def": { 321 | "enums": [ 322 | { 323 | "name": "TestEnum", 324 | "enum_fields": [ 325 | { 326 | "name": "FIRST" 327 | }, 328 | { 329 | "name": "SECOND", 330 | "integer": 1 331 | }, 332 | { 333 | "name": "SEGUNDO", 334 | "integer": 1 335 | } 336 | ], 337 | "reserved_ids": [ 338 | 2 339 | ], 340 | "options": [ 341 | { 342 | "name": "allow_alias", 343 | "value": "true" 344 | } 345 | ] 346 | }, 347 | { 348 | "name": "ContainsEnum.NestedEnum", 349 | "enum_fields": [ 350 | { 351 | "name": "ABC", 352 | "integer": 1 353 | }, 354 | { 355 | "name": "DEF", 356 | "integer": 2 357 | } 358 | ], 359 | "reserved_ids": [ 360 | 101 361 | ], 362 | "reserved_names": [ 363 | "DEPTH" 364 | ] 365 | } 366 | ], 367 | "messages": [ 368 | { 369 | "name": "TestRequest" 370 | }, 371 | { 372 | "name": "TestResponse" 373 | }, 374 | { 375 | "name": "Channel", 376 | "fields": [ 377 | { 378 | "id": 1, 379 | "name": "id", 380 | "type": "int64" 381 | }, 382 | { 383 | "id": 2, 384 | "name": "name", 385 | "type": "string" 386 | }, 387 | { 388 | "id": 3, 389 | "name": "description", 390 | "type": "string" 391 | }, 392 | { 393 | "id": 4, 394 | "name": "foo", 395 | "type": "string" 396 | }, 397 | { 398 | "id": 5, 399 | "name": "age", 400 | "type": "int32" 401 | }, 402 | { 403 | "id": 101, 404 | "name": "newnew", 405 | "type": "int32" 406 | }, 407 | { 408 | "id": 44, 409 | "name": "msg", 410 | "type": "A" 411 | } 412 | ], 413 | "reserved_ids": [ 414 | 6, 415 | 8, 416 | 9, 417 | 10, 418 | 11 419 | ], 420 | "messages": [ 421 | { 422 | "name": "A", 423 | "fields": [ 424 | { 425 | "id": 1, 426 | "name": "id", 427 | "type": "int32" 428 | } 429 | ] 430 | } 431 | ] 432 | }, 433 | { 434 | "name": "Display", 435 | "fields": [ 436 | { 437 | "id": 1, 438 | "name": "width", 439 | "type": "int32" 440 | }, 441 | { 442 | "id": 2, 443 | "name": "height", 444 | "type": "int32" 445 | }, 446 | { 447 | "id": 44, 448 | "name": "msg", 449 | "type": "A" 450 | } 451 | ], 452 | "maps": [ 453 | { 454 | "key_type": "string", 455 | "field": { 456 | "id": 4, 457 | "name": "b_map", 458 | "type": "int32" 459 | } 460 | } 461 | ], 462 | "reserved_ids": [ 463 | 3 464 | ], 465 | "reserved_names": [ 466 | "a_map", 467 | "single_quoted" 468 | ], 469 | "messages": [ 470 | { 471 | "name": "A", 472 | "fields": [ 473 | { 474 | "id": 1, 475 | "name": "id", 476 | "type": "int64" 477 | } 478 | ], 479 | "reserved_ids": [ 480 | 2 481 | ] 482 | } 483 | ] 484 | }, 485 | { 486 | "name": "ContainsEnum", 487 | "fields": [ 488 | { 489 | "id": 1, 490 | "name": "id", 491 | "type": "int32" 492 | }, 493 | { 494 | "id": 2, 495 | "name": "value", 496 | "type": "NestedEnum" 497 | } 498 | ] 499 | }, 500 | { 501 | "name": "PreviousRequest", 502 | "fields": [ 503 | { 504 | "id": 4, 505 | "name": "name", 506 | "type": "string", 507 | "oneof_parent": "test_oneof" 508 | }, 509 | { 510 | "id": 9, 511 | "name": "is_active", 512 | "type": "bool", 513 | "oneof_parent": "test_oneof" 514 | } 515 | ] 516 | }, 517 | { 518 | "name": "FloatIn", 519 | "fields": [ 520 | { 521 | "id": 1, 522 | "name": "val", 523 | "type": "float", 524 | "options": [ 525 | { 526 | "name": "(validate.rules).float", 527 | "aggregated": [ 528 | { 529 | "name": "in", 530 | "aggregated": [ 531 | { 532 | "value": "4.56" 533 | }, 534 | { 535 | "value": "7.89" 536 | } 537 | ] 538 | } 539 | ] 540 | } 541 | ] 542 | } 543 | ] 544 | } 545 | ], 546 | "services": [ 547 | { 548 | "name": "TestService", 549 | "rpcs": [ 550 | { 551 | "name": "TestRpc", 552 | "in_type": "TestRequest", 553 | "out_type": "TestResponse", 554 | "options": [ 555 | { 556 | "name": "(test_option)", 557 | "value": "option_value" 558 | }, 559 | { 560 | "name": "(test_option_2)", 561 | "value": "option_value_3" 562 | } 563 | ] 564 | } 565 | ] 566 | }, 567 | { 568 | "name": "ChannelChanger", 569 | "rpcs": [ 570 | { 571 | "name": "Next", 572 | "in_type": "NextRequest", 573 | "out_type": "Channel", 574 | "in_streamed": true 575 | }, 576 | { 577 | "name": "Previous", 578 | "in_type": "PreviousRequest", 579 | "out_type": "Channel", 580 | "out_streamed": true 581 | } 582 | ] 583 | } 584 | ], 585 | "package": { 586 | "name": "dataset" 587 | }, 588 | "options": [ 589 | { 590 | "name": "java_multiple_files", 591 | "value": "true" 592 | }, 593 | { 594 | "name": "java_package", 595 | "value": "test.java.package" 596 | }, 597 | { 598 | "name": "java_outer_classname", 599 | "value": "TestClass" 600 | } 601 | ] 602 | } 603 | } 604 | ] 605 | } -------------------------------------------------------------------------------- /protopath.go: -------------------------------------------------------------------------------- 1 | package protolock 2 | 3 | import ( 4 | "path/filepath" 5 | "strings" 6 | ) 7 | 8 | const ( 9 | // FileSep is the string representation of the OS-specific path separator. 10 | FileSep = string(filepath.Separator) 11 | 12 | // ProtoSep is an OS-ambiguous path separator to encode into the proto.lock 13 | // file. Use OsPath and ProtoPath funcs to convert. 14 | ProtoSep = ":/:" 15 | ) 16 | 17 | // Protopath is a type to assist in OS filepath transformations 18 | type Protopath string 19 | 20 | // OSPath converts a path in the Protopath format to the OS path format 21 | func OSPath(ProtoPath Protopath) Protopath { 22 | return Protopath( 23 | strings.Replace(string(ProtoPath), ProtoSep, FileSep, -1), 24 | ) 25 | } 26 | 27 | // ProtoPath converts a path in the OS path format to Protopath format 28 | func ProtoPath(OSPath Protopath) Protopath { 29 | return Protopath( 30 | strings.Replace(string(OSPath), FileSep, ProtoSep, -1), 31 | ) 32 | } 33 | 34 | func (p Protopath) String() string { 35 | return string(p) 36 | } 37 | -------------------------------------------------------------------------------- /protopath_test.go: -------------------------------------------------------------------------------- 1 | package protolock 2 | 3 | import ( 4 | "path/filepath" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | var osp = filepath.Join("testdata", "test.proto") 11 | 12 | func TestOSPathToProtoPath(t *testing.T) { 13 | path := Protopath(osp) 14 | p := ProtoPath(path) 15 | assert.Equal(t, "testdata:/:test.proto", string(p)) 16 | assert.Equal(t, Protopath("testdata:/:test.proto"), p) 17 | } 18 | 19 | func TestProtoPathToOSPath(t *testing.T) { 20 | path := Protopath("testdata:/:test.proto") 21 | p := OSPath(path) 22 | assert.Equal(t, Protopath(osp), p) 23 | assert.Equal(t, osp, string(p)) 24 | } 25 | -------------------------------------------------------------------------------- /release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set +x 4 | 5 | rootDir="$(pwd)" 6 | pkgDir="${rootDir}/pkg" 7 | 8 | rm -rf "${pkgDir}" 9 | mkdir -p "${pkgDir}" 10 | 11 | NOW="$(date -u +%Y%m%dT%H%M%SZ)" 12 | 13 | function build() { 14 | os=$1 15 | arch=$2 16 | 17 | extension='' 18 | if [ "${os}" == 'windows' ] 19 | then 20 | extension='.exe' 21 | fi 22 | 23 | name="protolock${extension}" 24 | 25 | tmpDir="${pkgDir}/tmp/${os}_${arch}" 26 | mkdir -p "${tmpDir}" 27 | GOOS="${os}" GOARCH="${arch}" go build -o "${tmpDir}/${name}" cmd/protolock/*.go 28 | ( 29 | cd "${tmpDir}" 30 | 31 | cp -p "${rootDir}"/{LICENSE,README.md} . 32 | 33 | distDir="${pkgDir}/distributions" 34 | mkdir -p "${distDir}" 35 | tar cpzf "${distDir}/protolock.${NOW}.${os}-${arch}.tgz" "${name}" LICENSE README.md 36 | ) 37 | } 38 | 39 | go vet 40 | go test 41 | for os in darwin linux windows 42 | do 43 | build "${os}" amd64 44 | done 45 | build linux arm64 46 | build darwin arm64 47 | -------------------------------------------------------------------------------- /report.go: -------------------------------------------------------------------------------- 1 | package protolock 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "sort" 7 | ) 8 | 9 | // HandleReport checks a report for warnigs and writes warnings to an io.Writer. 10 | // The returned int (an exit code) is 1 if warnings are encountered. 11 | func HandleReport(report *Report, w io.Writer, err error) (int, error) { 12 | if len(report.Warnings) > 0 { 13 | // sort the warnings so they are grouped by file location 14 | orderByPathAndMessage(report.Warnings) 15 | 16 | for _, warning := range report.Warnings { 17 | fmt.Fprintf( 18 | w, 19 | "CONFLICT: %s [%s]\n", 20 | warning.Message, warning.Filepath, 21 | ) 22 | } 23 | return 1, err 24 | } 25 | 26 | return 0, err 27 | } 28 | 29 | func orderByPathAndMessage(warnings []Warning) { 30 | sort.Slice(warnings, func(i, j int) bool { 31 | if warnings[i].Filepath < warnings[j].Filepath { 32 | return true 33 | } 34 | if warnings[i].Filepath > warnings[j].Filepath { 35 | return false 36 | } 37 | return warnings[i].Message < warnings[j].Message 38 | }) 39 | } 40 | -------------------------------------------------------------------------------- /rules.go: -------------------------------------------------------------------------------- 1 | package protolock 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | var ( 8 | // Rules provides a complete list of all funcs to be run to compare 9 | // a set of Protolocks. This list should be updated as new RuleFunc's 10 | // are added to this package. 11 | Rules = []Rule{ 12 | { 13 | Name: "NoUsingReservedFields", 14 | Func: NoUsingReservedFields, 15 | }, 16 | { 17 | Name: "NoRemovingReservedFields", 18 | Func: NoRemovingReservedFields, 19 | }, 20 | { 21 | Name: "NoRemovingFieldsWithoutReserve", 22 | Func: NoRemovingFieldsWithoutReserve, 23 | }, 24 | { 25 | Name: "NoChangingFieldIDs", 26 | Func: NoChangingFieldIDs, 27 | }, 28 | { 29 | Name: "NoChangingFieldTypes", 30 | Func: NoChangingFieldTypes, 31 | }, 32 | { 33 | Name: "NoChangingFieldNames", 34 | Func: NoChangingFieldNames, 35 | }, 36 | { 37 | Name: "NoRemovingRPCs", 38 | Func: NoRemovingRPCs, 39 | }, 40 | { 41 | Name: "NoChangingRPCSignature", 42 | Func: NoChangingRPCSignature, 43 | }, 44 | { 45 | Name: "NoMovingExistingFieldsIntoOrOutOfOneof", 46 | Func: NoMovingExistingFieldsIntoOrOutOfOneof, 47 | }, 48 | } 49 | 50 | strict = true 51 | debug = false 52 | ) 53 | 54 | const nestedPrefix = "." 55 | 56 | // SetStrict enables the user to toggle strict mode on and off. 57 | func SetStrict(mode bool) { 58 | strict = mode 59 | } 60 | 61 | // SetDebug enables the user to toggle debug mode on and off. 62 | func SetDebug(status bool) { 63 | debug = status 64 | } 65 | 66 | type Rule struct { 67 | Name string 68 | Func RuleFunc 69 | } 70 | 71 | // RuleFunc defines the common signature for a function which can compare 72 | // Protolock states and determine if issues exist. 73 | type RuleFunc func(current, updated Protolock) ([]Warning, bool) 74 | 75 | // lockIDsMap: 76 | // table of filepath -> message name -> reserved field ID -> times ID encountered 77 | // i.e. 78 | /* 79 | ["test.proto"] -> ["Test"] -> [1] -> 1 80 | 81 | -> ["User"] -> [1] -> 1 82 | [2] -> 1 83 | [3] -> 1 84 | 85 | -> ["Plan"] -> [1] -> 1 86 | [2] -> 1 87 | [3] -> 1 88 | */ 89 | type lockIDsMap map[Protopath]map[string]map[int]int 90 | 91 | // lockNamesMap: 92 | // table of filepath -> message name -> field name -> times name encountered (or the field ID) 93 | // i.e. 94 | /* 95 | ["test.proto"] -> ["Test"] -> ["field_one"] -> 1 96 | -> ["User"] -> ["field_one"] -> 1 97 | ["field_two"] -> 1 98 | ["field_three"] -> 1 99 | 100 | -> ["Plan"] -> ["field_one"] -> 1 101 | ["field_two"] -> 1 102 | ["field_three"] -> 1 103 | # if mapping field name -> id, 104 | -> ["Account"] -> ["field_one"] -> 1 105 | -> ["field_two"] -> 2 106 | -> ["field_three"] -> 3 107 | */ 108 | type lockNamesMap map[Protopath]map[string]map[string]int 109 | 110 | // lockFieldMap: 111 | // table of filepath -> message name -> field name -> field type 112 | type lockFieldMap map[Protopath]map[string]map[string]Field 113 | 114 | // lockEnumFieldMap: 115 | // table of filepath -> message name -> field name -> enum field type 116 | type lockEnumFieldMap map[Protopath]map[string]map[string]EnumField 117 | 118 | // lockMapMap: 119 | // table of filepath -> message name -> Map name -> Map type 120 | type lockMapMap map[Protopath]map[string]map[string]Map 121 | 122 | // lockRPCMap: 123 | // table of filepath -> service name -> rpc name -> rpc type 124 | type lockRPCMap map[Protopath]map[string]map[string]RPC 125 | 126 | // lockFieldIDNameMap: 127 | // table of filepath -> message name -> field ID -> field name 128 | type lockFieldIDNameMap map[Protopath]map[string]map[int]string 129 | 130 | func incMessageFields(reservedIDMap lockIDsMap, reservedNameMap lockNamesMap, filepath Protopath, prefix string, msg Message) { 131 | name := prefix + msg.Name 132 | for _, field := range msg.Fields { 133 | if reservedIDMap[filepath][name] == nil { 134 | reservedIDMap[filepath][name] = make(map[int]int) 135 | } 136 | if reservedNameMap[filepath][name] == nil { 137 | reservedNameMap[filepath][name] = make(map[string]int) 138 | } 139 | reservedIDMap[filepath][name][field.ID]++ 140 | reservedNameMap[filepath][name][field.Name]++ 141 | } 142 | for _, mp := range msg.Maps { 143 | if reservedIDMap[filepath][name] == nil { 144 | reservedIDMap[filepath][name] = make(map[int]int) 145 | } 146 | if reservedNameMap[filepath][name] == nil { 147 | reservedNameMap[filepath][name] = make(map[string]int) 148 | } 149 | reservedIDMap[filepath][name][mp.Field.ID]++ 150 | reservedNameMap[filepath][name][mp.Field.Name]++ 151 | } 152 | 153 | for _, m := range msg.Messages { 154 | incMessageFields( 155 | reservedIDMap, reservedNameMap, 156 | filepath, name+nestedPrefix, m, 157 | ) 158 | } 159 | } 160 | 161 | func incEnumFields(reservedIDMap lockIDsMap, reservedNameMap lockNamesMap, filepath Protopath, enum Enum) { 162 | for _, field := range enum.EnumFields { 163 | if reservedIDMap[filepath][enum.Name] == nil { 164 | reservedIDMap[filepath][enum.Name] = make(map[int]int) 165 | } 166 | if reservedNameMap[filepath][enum.Name] == nil { 167 | reservedNameMap[filepath][enum.Name] = make(map[string]int) 168 | } 169 | 170 | for id := range reservedIDMap[filepath][enum.Name] { 171 | if field.Integer == id { 172 | reservedIDMap[filepath][enum.Name][field.Integer]++ 173 | } 174 | } 175 | for name := range reservedNameMap[filepath][enum.Name] { 176 | if field.Name == name { 177 | reservedNameMap[filepath][enum.Name][field.Name]++ 178 | } 179 | } 180 | } 181 | } 182 | 183 | // NoUsingReservedFields compares the current vs. updated Protolock definitions 184 | // and will return a list of warnings if any message's previously reserved fields 185 | // or IDs are now being used as part of the same message. 186 | func NoUsingReservedFields(cur, upd Protolock) ([]Warning, bool) { 187 | reservedIDMap, reservedNameMap := getReservedFields(cur) 188 | reservedEnumIDMap, reservedEnumNameMap := getReservedEnumFields(cur) 189 | 190 | // add each messages field name/number to the existing list identified as 191 | // reserved to analyze 192 | for _, def := range upd.Definitions { 193 | if reservedIDMap[def.Filepath] == nil { 194 | reservedIDMap[def.Filepath] = make(map[string]map[int]int) 195 | } 196 | if reservedNameMap[def.Filepath] == nil { 197 | reservedNameMap[def.Filepath] = make(map[string]map[string]int) 198 | } 199 | for _, msg := range def.Def.Messages { 200 | incMessageFields( 201 | reservedIDMap, reservedNameMap, 202 | def.Filepath, "", msg, 203 | ) 204 | } 205 | 206 | if reservedEnumIDMap[def.Filepath] == nil { 207 | reservedEnumIDMap[def.Filepath] = make(map[string]map[int]int) 208 | } 209 | if reservedEnumNameMap[def.Filepath] == nil { 210 | reservedEnumNameMap[def.Filepath] = make(map[string]map[string]int) 211 | } 212 | for _, enum := range def.Def.Enums { 213 | incEnumFields( 214 | reservedEnumIDMap, reservedEnumNameMap, 215 | def.Filepath, enum, 216 | ) 217 | } 218 | } 219 | 220 | var warnings []Warning 221 | 222 | // Find message conflicts (using reserved names or IDs) 223 | 224 | // if the field ID was encountered more than once per message, then it 225 | // is known to be a re-use of a reserved field and a warning should be 226 | // returned for each occurrance 227 | for path, m := range reservedIDMap { 228 | for msgName, mm := range m { 229 | for id, count := range mm { 230 | if count > 1 { 231 | msg := fmt.Sprintf( 232 | `"%s" is re-using ID: %d, a reserved field number`, 233 | msgName, id, 234 | ) 235 | warnings = append(warnings, Warning{ 236 | Filepath: OSPath(path), 237 | Message: msg, 238 | }) 239 | } 240 | } 241 | } 242 | } 243 | // if the field name was encountered more than once per message, then it 244 | // is known to be a re-use of a reserved field and a warning should be 245 | // returned for each occurrance 246 | for path, m := range reservedNameMap { 247 | for msgName, mm := range m { 248 | for name, count := range mm { 249 | if count > 1 { 250 | msg := fmt.Sprintf( 251 | `"%s" is re-using name: "%s", a reserved field name`, 252 | msgName, name, 253 | ) 254 | warnings = append(warnings, Warning{ 255 | Filepath: OSPath(path), 256 | Message: msg, 257 | }) 258 | } 259 | } 260 | } 261 | } 262 | 263 | // Find enum conflicts (using reserved names or integers) 264 | 265 | // if the enum value was encountered more than once per message, then it 266 | // is known to be a re-use of a reserved field and a warning should be 267 | // returned for each occurrance 268 | for path, m := range reservedEnumIDMap { 269 | for enumName, mm := range m { 270 | for id, count := range mm { 271 | if count > 1 { 272 | msg := fmt.Sprintf( 273 | `"%s" is re-using integer: %d, a reserved value`, 274 | enumName, id, 275 | ) 276 | warnings = append(warnings, Warning{ 277 | Filepath: OSPath(path), 278 | Message: msg, 279 | }) 280 | } 281 | } 282 | } 283 | } 284 | // if the enum name was encountered more than once per message, then it 285 | // is known to be a re-use of a reserved field and a warning should be 286 | // returned for each occurrance 287 | for path, m := range reservedEnumNameMap { 288 | for enumName, mm := range m { 289 | for name, count := range mm { 290 | if count > 1 { 291 | msg := fmt.Sprintf( 292 | `"%s" is re-using name: "%s", a reserved name`, 293 | enumName, name, 294 | ) 295 | warnings = append(warnings, Warning{ 296 | Filepath: OSPath(path), 297 | Message: msg, 298 | }) 299 | } 300 | } 301 | } 302 | } 303 | 304 | if warnings != nil { 305 | return warnings, false 306 | } 307 | 308 | return nil, true 309 | } 310 | 311 | // NoRemovingReservedFields compares the current vs. updated Protolock definitions 312 | // and will return a list of warnings if any reserved field has been removed. This 313 | // rule is only enforced when strict mode is enabled. 314 | func NoRemovingReservedFields(cur, upd Protolock) ([]Warning, bool) { 315 | if !strict { 316 | return nil, true 317 | } 318 | 319 | var warnings []Warning 320 | // check that all reserved fields on current Protolock remain in the 321 | // updated Protolock 322 | 323 | // check all reserved fields on messages 324 | curReservedIDMap, curReservedNameMap := getReservedFields(cur) 325 | updReservedIDMap, updReservedNameMap := getReservedFields(upd) 326 | for path, msgMap := range curReservedIDMap { 327 | for msgName, idMap := range msgMap { 328 | for id := range idMap { 329 | if _, ok := updReservedIDMap[path][msgName][id]; !ok { 330 | msg := fmt.Sprintf( 331 | `"%s" is missing ID: %d, which had been reserved`, 332 | msgName, id, 333 | ) 334 | warnings = append(warnings, Warning{ 335 | Filepath: OSPath(path), 336 | Message: msg, 337 | }) 338 | } 339 | } 340 | } 341 | } 342 | for path, msgMap := range curReservedNameMap { 343 | for msgName, nameMap := range msgMap { 344 | for name := range nameMap { 345 | if _, ok := updReservedNameMap[path][msgName][name]; !ok { 346 | msg := fmt.Sprintf( 347 | `"%s" is missing name: "%s", which had been reserved`, 348 | msgName, name, 349 | ) 350 | warnings = append(warnings, Warning{ 351 | Filepath: OSPath(path), 352 | Message: msg, 353 | }) 354 | } 355 | } 356 | } 357 | } 358 | 359 | // check all reserved fields on enums 360 | curReservedEnumIDMap, curReservedEnumNameMap := getReservedEnumFields(cur) 361 | updReservedEnumIDMap, updReservedEnumNameMap := getReservedEnumFields(upd) 362 | for path, enumMap := range curReservedEnumIDMap { 363 | for enumName, idMap := range enumMap { 364 | for id := range idMap { 365 | if _, ok := updReservedEnumIDMap[path][enumName][id]; !ok { 366 | msg := fmt.Sprintf( 367 | `"%s" is missing integer: %d, which had been reserved`, 368 | enumName, id, 369 | ) 370 | warnings = append(warnings, Warning{ 371 | Filepath: OSPath(path), 372 | Message: msg, 373 | }) 374 | } 375 | } 376 | } 377 | } 378 | for path, enumMap := range curReservedEnumNameMap { 379 | for enumName, nameMap := range enumMap { 380 | for name := range nameMap { 381 | if _, ok := updReservedEnumNameMap[path][enumName][name]; !ok { 382 | msg := fmt.Sprintf( 383 | `"%s" is missing name: "%s", which had been reserved`, 384 | enumName, name, 385 | ) 386 | warnings = append(warnings, Warning{ 387 | Filepath: OSPath(path), 388 | Message: msg, 389 | }) 390 | } 391 | } 392 | } 393 | } 394 | 395 | if warnings != nil { 396 | return warnings, false 397 | } 398 | 399 | return nil, true 400 | } 401 | 402 | // NoChangingFieldIDs compares the current vs. updated Protolock definitions and 403 | // will return a list of warnings if any field ID number has been changed. 404 | func NoChangingFieldIDs(cur, upd Protolock) ([]Warning, bool) { 405 | var warnings []Warning 406 | 407 | // check all non-reserved message fields 408 | curNameIDMap := getNonReservedFields(cur) 409 | updNameIDMap := getNonReservedFields(upd) 410 | 411 | // check that all current Protolock names map to the same IDs as the 412 | // updated Protolock 413 | for path, msgMap := range curNameIDMap { 414 | for msgName, fieldMap := range msgMap { 415 | for fieldName, fieldID := range fieldMap { 416 | updFieldID, ok := updNameIDMap[path][msgName][fieldName] 417 | if ok { 418 | if updFieldID != fieldID { 419 | msg := fmt.Sprintf( 420 | `"%s" field: "%s" has a different ID: %d, previously %d`, 421 | msgName, fieldName, updFieldID, fieldID, 422 | ) 423 | warnings = append(warnings, Warning{ 424 | Filepath: OSPath(path), 425 | Message: msg, 426 | }) 427 | } 428 | } 429 | } 430 | } 431 | } 432 | 433 | // check all non-reserved enum fields 434 | curEnumNameIDMap := getNonReservedEnumFields(cur) 435 | updEnumNameIDMap := getNonReservedEnumFields(upd) 436 | 437 | // check that all current Protolock names map to the same IDs as the 438 | // updated Protolock 439 | for path, enumMap := range curEnumNameIDMap { 440 | for enumName, fieldMap := range enumMap { 441 | for fieldName, fieldInteger := range fieldMap { 442 | updFieldInteger, ok := updEnumNameIDMap[path][enumName][fieldName] 443 | if ok { 444 | if updFieldInteger != fieldInteger { 445 | msg := fmt.Sprintf( 446 | `"%s" field: "%s" has a different integer: %d, previously %d`, 447 | enumName, fieldName, updFieldInteger, fieldInteger, 448 | ) 449 | warnings = append(warnings, Warning{ 450 | Filepath: OSPath(path), 451 | Message: msg, 452 | }) 453 | } 454 | } 455 | } 456 | } 457 | } 458 | 459 | if warnings != nil { 460 | return warnings, false 461 | } 462 | 463 | return nil, true 464 | } 465 | 466 | // NoChangingFieldTypes compares the current vs. updated Protolock definitions and 467 | // will return a list of warnings if any field type has been changed. 468 | func NoChangingFieldTypes(cur, upd Protolock) ([]Warning, bool) { 469 | curFieldMap := getFieldMap(cur) 470 | updFieldMap := getFieldMap(upd) 471 | curMapMap := getMapMap(cur) 472 | updMapMap := getMapMap(upd) 473 | var warnings []Warning 474 | // check that the current Protolock message's field types are the same 475 | // for each of the same message's fields in the updated Protolock 476 | for path, msgMap := range curFieldMap { 477 | for msgName, fieldMap := range msgMap { 478 | for fieldName, field := range fieldMap { 479 | updField, ok := updFieldMap[path][msgName][fieldName] 480 | if ok { 481 | if updField.Type != field.Type { 482 | msg := fmt.Sprintf( 483 | `"%s" field: "%s" has a different type: %s, previously %s`, 484 | msgName, fieldName, updField.Type, field.Type, 485 | ) 486 | warnings = append(warnings, Warning{ 487 | Filepath: OSPath(path), 488 | Message: msg, 489 | }) 490 | } 491 | 492 | if updField.IsRepeated != field.IsRepeated { 493 | msg := fmt.Sprintf( 494 | `"%s" field: "%s" has a different "repeated" status: %t, previously %t`, 495 | msgName, fieldName, updField.IsRepeated, field.IsRepeated, 496 | ) 497 | warnings = append(warnings, Warning{ 498 | Filepath: OSPath(path), 499 | Message: msg, 500 | }) 501 | } 502 | } 503 | } 504 | } 505 | } 506 | 507 | // check that the current Protolock message's map types are the same 508 | // for each of the same message's maps in the updated Protolock 509 | for path, msgMap := range curMapMap { 510 | for msgName, mapMap := range msgMap { 511 | for fieldName, mp := range mapMap { 512 | updMap, ok := updMapMap[path][msgName][fieldName] 513 | if ok { 514 | if updMap.KeyType != mp.KeyType { 515 | msg := fmt.Sprintf( 516 | `"%s" field: "%s" has a different type: %s, previously %s`, 517 | msgName, fieldName, updMap.KeyType, mp.KeyType, 518 | ) 519 | warnings = append(warnings, Warning{ 520 | Filepath: OSPath(path), 521 | Message: msg, 522 | }) 523 | } 524 | } 525 | } 526 | } 527 | } 528 | 529 | if warnings != nil { 530 | return warnings, false 531 | } 532 | 533 | return nil, true 534 | } 535 | 536 | // NoChangingFieldNames compares the current vs. updated Protolock definitions and 537 | // will return a list of warnings if any message's previous fields have been 538 | // renamed. This rule is only enforced when strict mode is enabled. 539 | func NoChangingFieldNames(cur, upd Protolock) ([]Warning, bool) { 540 | if !strict { 541 | return nil, true 542 | } 543 | 544 | var warnings []Warning 545 | 546 | // check all field names of messages 547 | curFieldMap := getFieldsIDName(cur) 548 | updFieldMap := getFieldsIDName(upd) 549 | 550 | // check that the current Protolock messages' field names are equal to 551 | // their relative messages' field names in the updated Protolock 552 | for path, msgMap := range curFieldMap { 553 | for msgName, fieldMap := range msgMap { 554 | for fieldID, fieldName := range fieldMap { 555 | updFieldName, ok := updFieldMap[path][msgName][fieldID] 556 | if ok { 557 | if updFieldName != fieldName { 558 | msg := fmt.Sprintf( 559 | `"%s" field: "%s" ID: %d has an updated name, previously "%s"`, 560 | msgName, updFieldName, fieldID, fieldName, 561 | ) 562 | warnings = append(warnings, Warning{ 563 | Filepath: OSPath(path), 564 | Message: msg, 565 | }) 566 | } 567 | } 568 | } 569 | } 570 | } 571 | 572 | // check all field names of enums 573 | curEnumFieldMap := getEnumFieldsIDName(cur) 574 | updEnumFieldMap := getEnumFieldsIDName(upd) 575 | 576 | // check that the current Protolock enums' field names are equal to 577 | // their relative enums' field names in the updated Protolock 578 | for path, enumMap := range curEnumFieldMap { 579 | for enumName, fieldMap := range enumMap { 580 | for fieldInteger, fieldName := range fieldMap { 581 | updFieldName, ok := updEnumFieldMap[path][enumName][fieldInteger] 582 | if ok { 583 | if updFieldName != fieldName { 584 | msg := fmt.Sprintf( 585 | `"%s" field: "%s" integer: %d has an updated name, previously "%s"`, 586 | enumName, updFieldName, fieldInteger, fieldName, 587 | ) 588 | warnings = append(warnings, Warning{ 589 | Filepath: OSPath(path), 590 | Message: msg, 591 | }) 592 | } 593 | } 594 | } 595 | } 596 | } 597 | 598 | if warnings != nil { 599 | return warnings, false 600 | } 601 | 602 | return nil, true 603 | } 604 | 605 | // NoRemovingRPCs compares the current vs. updated Protolock definitions and 606 | // will return a list of warnings if any RPCs provided by a Service have been 607 | // removed. This rule is only enforced when strict mode is enabled. 608 | func NoRemovingRPCs(cur, upd Protolock) ([]Warning, bool) { 609 | if !strict { 610 | return nil, true 611 | } 612 | 613 | var warnings []Warning 614 | // check that all current Protolock services' RPCs are still in the 615 | // updated Protolock 616 | curServices := getServicesRPCsMap(cur) 617 | updServices := getServicesRPCsMap(upd) 618 | 619 | for path, svcMap := range curServices { 620 | for svcName, rpcMap := range svcMap { 621 | for rpcName := range rpcMap { 622 | _, ok := updServices[path][svcName][rpcName] 623 | if !ok { 624 | msg := fmt.Sprintf( 625 | `"%s" is missing RPC: "%s", which should be available`, 626 | svcName, rpcName, 627 | ) 628 | warnings = append(warnings, Warning{ 629 | Filepath: OSPath(path), 630 | Message: msg, 631 | }) 632 | } 633 | } 634 | } 635 | } 636 | 637 | if warnings != nil { 638 | return warnings, false 639 | } 640 | 641 | return nil, true 642 | } 643 | 644 | // NoRemovingFieldsWithoutReserve compares the current vs. updated Protolock 645 | // definitions and will return a list of warnings if any field has been removed 646 | // without a corresponding reservation of that field name or ID. 647 | func NoRemovingFieldsWithoutReserve(cur, upd Protolock) ([]Warning, bool) { 648 | var warnings []Warning 649 | 650 | // check all message fields 651 | curFieldMap := getFieldMap(cur) 652 | updFieldMap := getFieldMap(upd) 653 | 654 | // check that if a field name from the current Protolock is not retained 655 | // in the updated Protolock, then the field's name and ID should become 656 | // reserved within the parent message 657 | for path, msgMap := range curFieldMap { 658 | for msgName, fieldMap := range msgMap { 659 | encounteredIDs := make(map[int]int) 660 | for _, field := range updFieldMap[path][msgName] { 661 | encounteredIDs[field.ID]++ 662 | } 663 | for fieldName, field := range fieldMap { 664 | _, ok := updFieldMap[path][msgName][fieldName] 665 | if !ok { 666 | // check that the field name and ID are 667 | // both in the reserved fields for this 668 | // message 669 | resIDsMap, resNamesMap := getReservedFields(upd) 670 | if _, ok := resNamesMap[path][msgName][field.Name]; !ok { 671 | msg := fmt.Sprintf( 672 | `"%s" field: "%s" has been removed, but is not reserved`, 673 | msgName, field.Name, 674 | ) 675 | warnings = append(warnings, Warning{ 676 | Filepath: OSPath(path), 677 | Message: msg, 678 | }) 679 | } 680 | 681 | // check that the ID for this missing field is being re-used 682 | // in which case will be caught by NoChangingFieldNames 683 | if _, ok := encounteredIDs[field.ID]; ok { 684 | continue 685 | } 686 | 687 | if _, ok := resIDsMap[path][msgName][field.ID]; !ok { 688 | msg := fmt.Sprintf( 689 | `"%s" ID: "%d" has been removed, but is not reserved`, 690 | msgName, field.ID, 691 | ) 692 | warnings = append(warnings, Warning{ 693 | Filepath: OSPath(path), 694 | Message: msg, 695 | }) 696 | } 697 | } 698 | } 699 | } 700 | } 701 | 702 | // check all enum fields 703 | curEnumFieldMap := getEnumFieldMap(cur) 704 | updEnumFieldMap := getEnumFieldMap(upd) 705 | 706 | // check that if a field name from the current Protolock is not retained 707 | // in the updated Protolock, then the field's name and integer should 708 | // become reserved within the parent enum 709 | for path, enumMap := range curEnumFieldMap { 710 | for enumName, fieldMap := range enumMap { 711 | encounteredIDs := make(map[int]int) 712 | for _, field := range updEnumFieldMap[path][enumName] { 713 | encounteredIDs[field.Integer]++ 714 | } 715 | for fieldName, field := range fieldMap { 716 | _, ok := updEnumFieldMap[path][enumName][fieldName] 717 | if !ok { 718 | // check that the field name and ID are 719 | // both in the reserved fields for this 720 | // enum 721 | resIDsMap, resNamesMap := getReservedEnumFields(upd) 722 | if _, ok := resNamesMap[path][enumName][field.Name]; !ok { 723 | msg := fmt.Sprintf( 724 | `"%s" field: "%s" has been removed, but is not reserved`, 725 | enumName, field.Name, 726 | ) 727 | warnings = append(warnings, Warning{ 728 | Filepath: OSPath(path), 729 | Message: msg, 730 | }) 731 | } 732 | 733 | // check that the integer for this missing field is being re-used 734 | // in which case will be caught by NoChangingFieldNames 735 | if _, ok := encounteredIDs[field.Integer]; ok { 736 | continue 737 | } 738 | 739 | if _, ok := resIDsMap[path][enumName][field.Integer]; !ok { 740 | msg := fmt.Sprintf( 741 | `"%s" integer: "%d" has been removed, but is not reserved`, 742 | enumName, field.Integer, 743 | ) 744 | warnings = append(warnings, Warning{ 745 | Filepath: OSPath(path), 746 | Message: msg, 747 | }) 748 | } 749 | } 750 | } 751 | } 752 | } 753 | 754 | if warnings != nil { 755 | return warnings, false 756 | } 757 | 758 | return nil, true 759 | } 760 | 761 | // NoChangingRPCSignature compares the current vs. updated Protolock 762 | // definitions and will return a list of warnings if any RPC signature has been 763 | // changed while using the same name. 764 | func NoChangingRPCSignature(cur, upd Protolock) ([]Warning, bool) { 765 | var warnings []Warning 766 | // check that no breaking changes to the signature of an RPC have been 767 | // made between the current Protolock and the updated Protolock 768 | curRPCMap := getRPCMap(cur) 769 | updRPCMap := getRPCMap(upd) 770 | for path, svcMap := range curRPCMap { 771 | for svcName, rpcMap := range svcMap { 772 | for rpcName, rpc := range rpcMap { 773 | updRPC, ok := updRPCMap[path][svcName][rpcName] 774 | if !ok { 775 | continue 776 | } 777 | 778 | // check that stream option and type are the same 779 | // for both the RPC's request and response 780 | if rpc.InStreamed != updRPC.InStreamed { 781 | msg := fmt.Sprintf( 782 | `"%s" RPC: "%s" input stream identifier has changed, previously: %t`, 783 | svcName, rpcName, rpc.InStreamed, 784 | ) 785 | warnings = append(warnings, Warning{ 786 | Filepath: OSPath(path), 787 | Message: msg, 788 | }) 789 | } 790 | 791 | if rpc.OutStreamed != updRPC.OutStreamed { 792 | msg := fmt.Sprintf( 793 | `"%s" RPC: "%s" output stream identifier has changed, previously: %t`, 794 | svcName, rpcName, rpc.OutStreamed, 795 | ) 796 | warnings = append(warnings, Warning{ 797 | Filepath: OSPath(path), 798 | Message: msg, 799 | }) 800 | } 801 | 802 | if rpc.InType != updRPC.InType { 803 | msg := fmt.Sprintf( 804 | `"%s" RPC: "%s" input type has changed, previously: %s`, 805 | svcName, rpcName, rpc.InType, 806 | ) 807 | warnings = append(warnings, Warning{ 808 | Filepath: OSPath(path), 809 | Message: msg, 810 | }) 811 | } 812 | 813 | if rpc.OutType != updRPC.OutType { 814 | msg := fmt.Sprintf( 815 | `"%s" RPC: "%s" output type has changed, previously: %s`, 816 | svcName, rpcName, rpc.OutType, 817 | ) 818 | warnings = append(warnings, Warning{ 819 | Filepath: OSPath(path), 820 | Message: msg, 821 | }) 822 | } 823 | } 824 | } 825 | } 826 | 827 | if warnings != nil { 828 | return warnings, false 829 | } 830 | 831 | return nil, true 832 | } 833 | 834 | // Existing fields must not be moved into or out of a oneof. This is a backwards-incompatible change in the Go protobuf stubs. 835 | // per https://google.aip.dev/180#moving-into-oneofs 836 | func NoMovingExistingFieldsIntoOrOutOfOneof(cur, upd Protolock) ([]Warning, bool) { 837 | var warnings []Warning 838 | 839 | // check all message fields 840 | curFieldMap := getFieldMap(cur) 841 | updFieldMap := getFieldMap(upd) 842 | 843 | // if a field name from the current Protolock has a OneofParent entry 844 | // that differs from the updated Protolock, then a warning should be added 845 | for path, msgMap := range curFieldMap { 846 | for msgName, fieldMap := range msgMap { 847 | for fieldName, field := range fieldMap { 848 | updField, ok := updFieldMap[path][msgName][fieldName] 849 | if ok && updField.OneofParent != field.OneofParent { 850 | if len(updField.OneofParent) == 0 { 851 | msg := fmt.Sprintf( 852 | `"%s" was moved out of oneof "%s"`, 853 | fieldName, field.OneofParent, 854 | ) 855 | warnings = append(warnings, Warning{ 856 | Filepath: OSPath(path), 857 | Message: msg, 858 | }) 859 | } else if len(field.OneofParent) == 0 { 860 | msg := fmt.Sprintf( 861 | `"%s" was moved into oneof "%s"`, 862 | fieldName, updField.OneofParent, 863 | ) 864 | warnings = append(warnings, Warning{ 865 | Filepath: OSPath(path), 866 | Message: msg, 867 | }) 868 | } else { 869 | msg := fmt.Sprintf( 870 | `"%s" was moved from oneof "%s" into of oneof "%s"`, 871 | fieldName, field.OneofParent, updField.OneofParent, 872 | ) 873 | warnings = append(warnings, Warning{ 874 | Filepath: OSPath(path), 875 | Message: msg, 876 | }) 877 | } 878 | } 879 | } 880 | } 881 | } 882 | 883 | if warnings != nil { 884 | return warnings, false 885 | } 886 | 887 | return nil, true 888 | } 889 | 890 | func getReservedFieldsRecursive(reservedIDMap lockIDsMap, reservedNameMap lockNamesMap, filepath Protopath, prefix string, msg Message) { 891 | msgName := prefix + msg.Name 892 | for _, id := range msg.ReservedIDs { 893 | if reservedIDMap[filepath][msgName] == nil { 894 | reservedIDMap[filepath][msgName] = make(map[int]int) 895 | } 896 | reservedIDMap[filepath][msgName][id]++ 897 | } 898 | for _, name := range msg.ReservedNames { 899 | if reservedNameMap[filepath][msgName] == nil { 900 | reservedNameMap[filepath][msgName] = make(map[string]int) 901 | } 902 | reservedNameMap[filepath][msgName][name]++ 903 | } 904 | 905 | for _, msg := range msg.Messages { 906 | // recursively call func, using parent message name and a '.' as prefix 907 | getReservedFieldsRecursive(reservedIDMap, reservedNameMap, filepath, msgName+nestedPrefix, msg) 908 | } 909 | } 910 | 911 | // getReservedFields gets all the reserved field numbers and names, and stashes 912 | // them in a lockIDsMap and lockNamesMap to be checked against. 913 | func getReservedFields(lock Protolock) (lockIDsMap, lockNamesMap) { 914 | reservedIDMap := make(lockIDsMap) 915 | reservedNameMap := make(lockNamesMap) 916 | 917 | for _, def := range lock.Definitions { 918 | if reservedIDMap[def.Filepath] == nil { 919 | reservedIDMap[def.Filepath] = make(map[string]map[int]int) 920 | } 921 | if reservedNameMap[def.Filepath] == nil { 922 | reservedNameMap[def.Filepath] = make(map[string]map[string]int) 923 | } 924 | 925 | for _, msg := range def.Def.Messages { 926 | getReservedFieldsRecursive(reservedIDMap, reservedNameMap, def.Filepath, "", msg) 927 | } 928 | } 929 | 930 | return reservedIDMap, reservedNameMap 931 | } 932 | 933 | func getReservedEnumFields(lock Protolock) (lockIDsMap, lockNamesMap) { 934 | reservedIDMap := make(lockIDsMap) 935 | reservedNameMap := make(lockNamesMap) 936 | 937 | for _, def := range lock.Definitions { 938 | if reservedIDMap[def.Filepath] == nil { 939 | reservedIDMap[def.Filepath] = make(map[string]map[int]int) 940 | } 941 | if reservedNameMap[def.Filepath] == nil { 942 | reservedNameMap[def.Filepath] = make(map[string]map[string]int) 943 | } 944 | 945 | for _, enum := range def.Def.Enums { 946 | for _, id := range enum.ReservedIDs { 947 | if reservedIDMap[def.Filepath][enum.Name] == nil { 948 | reservedIDMap[def.Filepath][enum.Name] = make(map[int]int) 949 | } 950 | reservedIDMap[def.Filepath][enum.Name][id]++ 951 | } 952 | for _, name := range enum.ReservedNames { 953 | if reservedNameMap[def.Filepath][enum.Name] == nil { 954 | reservedNameMap[def.Filepath][enum.Name] = make(map[string]int) 955 | } 956 | reservedNameMap[def.Filepath][enum.Name][name]++ 957 | } 958 | } 959 | } 960 | 961 | return reservedIDMap, reservedNameMap 962 | } 963 | 964 | func getFieldIDNameRecursive(fieldIDNameMap lockFieldIDNameMap, filepath Protopath, prefix string, msg Message) { 965 | msgName := prefix + msg.Name 966 | for _, field := range msg.Fields { 967 | if fieldIDNameMap[filepath][msgName] == nil { 968 | fieldIDNameMap[filepath][msgName] = make(map[int]string) 969 | } 970 | fieldIDNameMap[filepath][msgName][field.ID] = field.Name 971 | } 972 | for _, mp := range msg.Maps { 973 | if fieldIDNameMap[filepath][msgName] == nil { 974 | fieldIDNameMap[filepath][msgName] = make(map[int]string) 975 | } 976 | fieldIDNameMap[filepath][msgName][mp.Field.ID] = mp.Field.Name 977 | } 978 | for _, nestedMsg := range msg.Messages { 979 | getFieldIDNameRecursive(fieldIDNameMap, filepath, msgName+nestedPrefix, nestedMsg) 980 | } 981 | } 982 | 983 | // getFieldsIDName gets all the fields mapped by the field ID to its name for 984 | // all messages. 985 | func getFieldsIDName(lock Protolock) lockFieldIDNameMap { 986 | fieldIDNameMap := make(lockFieldIDNameMap) 987 | 988 | for _, def := range lock.Definitions { 989 | if fieldIDNameMap[def.Filepath] == nil { 990 | fieldIDNameMap[def.Filepath] = make(map[string]map[int]string) 991 | } 992 | for _, msg := range def.Def.Messages { 993 | getFieldIDNameRecursive(fieldIDNameMap, def.Filepath, "", msg) 994 | } 995 | } 996 | 997 | return fieldIDNameMap 998 | } 999 | 1000 | // getEnumFieldsIDName gets all the fields mapped by the field ID to its name 1001 | // for all enums. 1002 | func getEnumFieldsIDName(lock Protolock) lockFieldIDNameMap { 1003 | fieldIDNameMap := make(lockFieldIDNameMap) 1004 | 1005 | for _, def := range lock.Definitions { 1006 | if fieldIDNameMap[def.Filepath] == nil { 1007 | fieldIDNameMap[def.Filepath] = make(map[string]map[int]string) 1008 | } 1009 | for _, enum := range def.Def.Enums { 1010 | for _, field := range enum.EnumFields { 1011 | if fieldIDNameMap[def.Filepath][enum.Name] == nil { 1012 | fieldIDNameMap[def.Filepath][enum.Name] = make(map[int]string) 1013 | } 1014 | fieldIDNameMap[def.Filepath][enum.Name][field.Integer] = field.Name 1015 | } 1016 | } 1017 | } 1018 | 1019 | return fieldIDNameMap 1020 | } 1021 | 1022 | func getNonReservedFieldsRecursive(nameIDMap lockNamesMap, filepath Protopath, prefix string, msg Message) { 1023 | msgName := prefix + msg.Name 1024 | for _, field := range msg.Fields { 1025 | if nameIDMap[filepath][msgName] == nil { 1026 | nameIDMap[filepath][msgName] = make(map[string]int) 1027 | } 1028 | nameIDMap[filepath][msgName][field.Name] = field.ID 1029 | } 1030 | for _, mp := range msg.Maps { 1031 | if nameIDMap[filepath][msgName] == nil { 1032 | nameIDMap[filepath][msgName] = make(map[string]int) 1033 | } 1034 | nameIDMap[filepath][msgName][mp.Field.Name] = mp.Field.ID 1035 | } 1036 | for _, nestedMsg := range msg.Messages { 1037 | getNonReservedFieldsRecursive(nameIDMap, filepath, msgName+nestedPrefix, nestedMsg) 1038 | } 1039 | } 1040 | 1041 | // getNonReservedFields gets all the non-reserved field numbers and names, and 1042 | // stashes them in a lockNamesMap to be checked against. 1043 | func getNonReservedFields(lock Protolock) lockNamesMap { 1044 | nameIDMap := make(lockNamesMap) 1045 | 1046 | for _, def := range lock.Definitions { 1047 | if nameIDMap[def.Filepath] == nil { 1048 | nameIDMap[def.Filepath] = make(map[string]map[string]int) 1049 | } 1050 | for _, msg := range def.Def.Messages { 1051 | getNonReservedFieldsRecursive(nameIDMap, def.Filepath, "", msg) 1052 | } 1053 | } 1054 | 1055 | return nameIDMap 1056 | } 1057 | 1058 | // getNonReservedEnumFields gets all the non-reserved field numbers and names, 1059 | // and stashes them in a lockNamesMap to be checked against. 1060 | func getNonReservedEnumFields(lock Protolock) lockNamesMap { 1061 | nameIDMap := make(lockNamesMap) 1062 | 1063 | for _, def := range lock.Definitions { 1064 | if nameIDMap[def.Filepath] == nil { 1065 | nameIDMap[def.Filepath] = make(map[string]map[string]int) 1066 | } 1067 | for _, enum := range def.Def.Enums { 1068 | for _, field := range enum.EnumFields { 1069 | if nameIDMap[def.Filepath][enum.Name] == nil { 1070 | nameIDMap[def.Filepath][enum.Name] = make(map[string]int) 1071 | } 1072 | nameIDMap[def.Filepath][enum.Name][field.Name] = field.Integer 1073 | } 1074 | } 1075 | } 1076 | 1077 | return nameIDMap 1078 | } 1079 | 1080 | func getMapMapRecursive(nameTypeMap lockMapMap, filepath Protopath, prefix string, msg Message) { 1081 | msgName := prefix + msg.Name 1082 | for _, mp := range msg.Maps { 1083 | if nameTypeMap[filepath][msgName] == nil { 1084 | nameTypeMap[filepath][msgName] = make(map[string]Map) 1085 | } 1086 | nameTypeMap[filepath][msgName][mp.Field.Name] = mp 1087 | } 1088 | for _, nestedMsg := range msg.Messages { 1089 | 1090 | getMapMapRecursive(nameTypeMap, filepath, msgName+nestedPrefix, nestedMsg) 1091 | } 1092 | } 1093 | 1094 | // getMapMap gets all the map names and types, and stashes them in a 1095 | // lockMapMap to be checked against. 1096 | func getMapMap(lock Protolock) lockMapMap { 1097 | nameTypeMap := make(lockMapMap) 1098 | 1099 | for _, def := range lock.Definitions { 1100 | if nameTypeMap[def.Filepath] == nil { 1101 | nameTypeMap[def.Filepath] = make(map[string]map[string]Map) 1102 | } 1103 | for _, msg := range def.Def.Messages { 1104 | getMapMapRecursive(nameTypeMap, def.Filepath, "", msg) 1105 | } 1106 | } 1107 | 1108 | return nameTypeMap 1109 | } 1110 | 1111 | func getFieldMapRecursive(nameTypeMap lockFieldMap, filepath Protopath, prefix string, msg Message) { 1112 | msgName := prefix + msg.Name 1113 | for _, field := range msg.Fields { 1114 | if nameTypeMap[filepath][msgName] == nil { 1115 | nameTypeMap[filepath][msgName] = make(map[string]Field) 1116 | } 1117 | nameTypeMap[filepath][msgName][field.Name] = field 1118 | } 1119 | for _, mp := range msg.Maps { 1120 | if nameTypeMap[filepath][msgName] == nil { 1121 | nameTypeMap[filepath][msgName] = make(map[string]Field) 1122 | } 1123 | nameTypeMap[filepath][msgName][mp.Field.Name] = mp.Field 1124 | } 1125 | for _, nestedMsg := range msg.Messages { 1126 | getFieldMapRecursive(nameTypeMap, filepath, msgName+nestedPrefix, nestedMsg) 1127 | } 1128 | } 1129 | 1130 | // getFieldMap gets all the field names and types, and stashes them in a 1131 | // lockFieldMap to be checked against. 1132 | func getFieldMap(lock Protolock) lockFieldMap { 1133 | nameTypeMap := make(lockFieldMap) 1134 | 1135 | for _, def := range lock.Definitions { 1136 | if nameTypeMap[def.Filepath] == nil { 1137 | nameTypeMap[def.Filepath] = make(map[string]map[string]Field) 1138 | } 1139 | for _, msg := range def.Def.Messages { 1140 | getFieldMapRecursive(nameTypeMap, def.Filepath, "", msg) 1141 | } 1142 | } 1143 | 1144 | return nameTypeMap 1145 | } 1146 | 1147 | // getEnumFieldMap gets all the field names and types, and stashes them in a 1148 | // lockEnumFieldMap to be checked against. 1149 | func getEnumFieldMap(lock Protolock) lockEnumFieldMap { 1150 | nameTypeMap := make(lockEnumFieldMap) 1151 | 1152 | for _, def := range lock.Definitions { 1153 | if nameTypeMap[def.Filepath] == nil { 1154 | nameTypeMap[def.Filepath] = make(map[string]map[string]EnumField) 1155 | } 1156 | for _, enum := range def.Def.Enums { 1157 | for _, field := range enum.EnumFields { 1158 | if nameTypeMap[def.Filepath][enum.Name] == nil { 1159 | nameTypeMap[def.Filepath][enum.Name] = make(map[string]EnumField) 1160 | } 1161 | nameTypeMap[def.Filepath][enum.Name][field.Name] = field 1162 | } 1163 | } 1164 | } 1165 | 1166 | return nameTypeMap 1167 | } 1168 | 1169 | // getServicesRPCsMap gets all the RPCs for the Services in a Protolock and 1170 | // stashes them in a lockNamesMap to be checked against. 1171 | func getServicesRPCsMap(lock Protolock) lockNamesMap { 1172 | servicesRPCsMap := make(lockNamesMap) 1173 | for _, def := range lock.Definitions { 1174 | if servicesRPCsMap[def.Filepath] == nil { 1175 | servicesRPCsMap[def.Filepath] = make(map[string]map[string]int) 1176 | } 1177 | for _, svc := range def.Def.Services { 1178 | if servicesRPCsMap[def.Filepath][svc.Name] == nil { 1179 | servicesRPCsMap[def.Filepath][svc.Name] = make(map[string]int) 1180 | } 1181 | for _, rpc := range svc.RPCs { 1182 | servicesRPCsMap[def.Filepath][svc.Name][rpc.Name]++ 1183 | } 1184 | } 1185 | } 1186 | 1187 | return servicesRPCsMap 1188 | } 1189 | 1190 | // getRPCMap gets all the RPC names and types, and stashes them in a 1191 | // lockRPCMap to be checked against. 1192 | func getRPCMap(lock Protolock) lockRPCMap { 1193 | rpcTypeMap := make(lockRPCMap) 1194 | 1195 | for _, def := range lock.Definitions { 1196 | if rpcTypeMap[def.Filepath] == nil { 1197 | rpcTypeMap[def.Filepath] = make(map[string]map[string]RPC) 1198 | } 1199 | for _, svc := range def.Def.Services { 1200 | for _, rpc := range svc.RPCs { 1201 | if rpcTypeMap[def.Filepath][svc.Name] == nil { 1202 | rpcTypeMap[def.Filepath][svc.Name] = make(map[string]RPC) 1203 | } 1204 | rpcTypeMap[def.Filepath][svc.Name][rpc.Name] = rpc 1205 | } 1206 | } 1207 | } 1208 | 1209 | return rpcTypeMap 1210 | } 1211 | 1212 | func beginRuleDebug(name string) { 1213 | fmt.Println("RUN RULE:", name) 1214 | } 1215 | 1216 | func concludeRuleDebug(name string, warnings []Warning) { 1217 | fmt.Println("# Warnings:", len(warnings)) 1218 | for i, w := range warnings { 1219 | msg := fmt.Sprintf("%d). %s [%s]", i+1, w.Message, w.Filepath) 1220 | fmt.Println(msg) 1221 | } 1222 | fmt.Println("END RULE:", name) 1223 | fmt.Println("===") 1224 | } 1225 | -------------------------------------------------------------------------------- /rules_test.go: -------------------------------------------------------------------------------- 1 | package protolock 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | const simpleProto = `syntax = "proto3"; 11 | package test; 12 | 13 | message Channel { 14 | int64 id = 1; 15 | string name = 2; 16 | string description = 3; 17 | } 18 | 19 | message NextRequest {} 20 | message PreviousRequest {} 21 | 22 | service ChannelChanger { 23 | rpc Next(stream NextRequest) returns (Channel); 24 | rpc Previous(PreviousRequest) returns (stream Channel); 25 | } 26 | ` 27 | 28 | const noUsingReservedFieldsProto = `syntax = "proto3"; 29 | package test; 30 | 31 | message Channel { 32 | reserved 4, 8 to 11; 33 | reserved "foo", "bar"; 34 | int64 id = 1; 35 | string name = 2; 36 | string description = 3; 37 | 38 | message InChannel { 39 | reserved 4; 40 | reserved "not_here"; 41 | string name = 1; 42 | int64 id = 2; 43 | bool foo = 3; 44 | } 45 | } 46 | 47 | message Request { 48 | reserved 2; 49 | reserved "field2"; 50 | .example.snth.Field field1 = 1; 51 | } 52 | 53 | message NextRequest { 54 | reserved 3; 55 | reserved "a_map"; 56 | } 57 | 58 | message PreviousRequest { 59 | reserved 4; 60 | reserved "no_use"; 61 | oneof test_oneof { 62 | int64 id = 1; 63 | bool is_active = 2; 64 | } 65 | } 66 | 67 | enum WithAllowAlias { 68 | reserved "DONTUSE"; 69 | reserved 2; 70 | option allow_alias = true; 71 | UNKNOWN = 0; 72 | STARTED = 1; 73 | RUNNING = 1; 74 | } 75 | 76 | enum NoWithAllowAlias { 77 | reserved "DONTUSE2"; 78 | reserved 2; 79 | UNKNOWN2 = 0; 80 | STARTED2 = 1; 81 | } 82 | 83 | message IHaveAnEnum { 84 | int32 id = 1; 85 | 86 | enum IAmTheEnum { 87 | reserved "NONE"; 88 | reserved 101; 89 | ALL = 0; 90 | SOME = 100; 91 | } 92 | } 93 | 94 | service ChannelChanger { 95 | rpc Next(stream NextRequest) returns (Channel); 96 | rpc Previous(PreviousRequest) returns (stream Channel); 97 | } 98 | ` 99 | 100 | const usingReservedFieldsProto = `syntax = "proto3"; 101 | package test; 102 | 103 | message Channel { 104 | int64 id = 1; 105 | string name = 2; 106 | string description = 3; 107 | string foo = 4; 108 | bool bar = 5; 109 | 110 | message A { 111 | int32 id = 1; 112 | string name = 2; 113 | } 114 | 115 | message InChannel { 116 | string name = 1; 117 | int64 id = 2; 118 | bool foo = 3; 119 | bool not_here = 4; 120 | } 121 | } 122 | 123 | message Request { 124 | .example.snth.Field field1 = 1; 125 | .example.snth.Field field2 = 2; 126 | } 127 | 128 | message NextRequest { 129 | string name = 1; 130 | map a_map = 3; 131 | } 132 | 133 | message PreviousRequest { 134 | oneof test_oneof { 135 | int64 id = 1; 136 | bool is_active = 2; 137 | string no_use = 3; 138 | float thing = 4; 139 | } 140 | } 141 | 142 | enum WithAllowAlias { 143 | option allow_alias = true; 144 | UNKNOWN = 0; 145 | STARTED = 1; 146 | RUNNING = 1; 147 | STOPPED = 2; 148 | DONTUSE = 3; 149 | } 150 | 151 | enum NoWithAllowAlias { 152 | UNKNOWN2 = 0; 153 | STARTED2 = 1; 154 | DONTUSE2 = 1; 155 | STOPPED2 = 2; 156 | } 157 | 158 | message IHaveAnEnum { 159 | int32 id = 1; 160 | 161 | enum IAmTheEnum { 162 | ALL = 0; 163 | SOME = 100; 164 | NONE = 1; 165 | FEW = 101; 166 | } 167 | } 168 | 169 | service ChannelChanger { 170 | rpc Next(stream NextRequest) returns (Channel); 171 | rpc Previous(PreviousRequest) returns (stream Channel); 172 | } 173 | ` 174 | 175 | const noRemoveReservedFieldsProto = `syntax = "proto3"; 176 | package test; 177 | 178 | message Channel { 179 | reserved 44, 101, 103 to 110; 180 | reserved "no_more", "goodbye"; 181 | int64 id = 1; 182 | string name = 2; 183 | string description = 3; 184 | string foo = 4; 185 | bool bar = 5; 186 | 187 | message InChannel { 188 | reserved 4; 189 | reserved "not_here"; 190 | string name = 1; 191 | int64 id = 2; 192 | bool foo = 3; 193 | } 194 | } 195 | 196 | message NextRequest { 197 | reserved 3; 198 | reserved "a_map"; 199 | } 200 | 201 | message PreviousRequest { 202 | reserved 4; 203 | reserved "no_use"; 204 | oneof test_oneof { 205 | int64 id = 1; 206 | bool is_active = 2; 207 | } 208 | 209 | enum NestedEnum { 210 | reserved 11; 211 | reserved "NOPE"; 212 | 213 | LOCATION = 1; 214 | } 215 | 216 | NestedEnum value = 3; 217 | } 218 | 219 | enum AnotherEnum { 220 | reserved 2; 221 | reserved "DONTUSEIT"; 222 | 223 | option allow_alias = true; 224 | 225 | USE = 2; 226 | OK = 3; 227 | FINE = 3; 228 | } 229 | 230 | service ChannelChanger { 231 | rpc Next(stream NextRequest) returns (Channel); 232 | rpc Previous(PreviousRequest) returns (stream Channel); 233 | } 234 | ` 235 | 236 | const removeReservedFieldsProto = `syntax = "proto3"; 237 | package test; 238 | 239 | message Channel { 240 | reserved 101, 103 to 107; 241 | reserved "no_more"; 242 | int64 id = 1; 243 | string name = 2; 244 | string description = 3; 245 | string foo = 4; 246 | bool bar = 5; 247 | 248 | message InChannel { 249 | string name = 1; 250 | int64 id = 2; 251 | bool foo = 3; 252 | } 253 | } 254 | 255 | message NextRequest { 256 | map a_map = 3; 257 | } 258 | 259 | message PreviousRequest { 260 | oneof test_oneof { 261 | int64 id = 1; 262 | bool is_active = 2; 263 | } 264 | 265 | enum NestedEnum { 266 | LOCATION = 1; 267 | } 268 | 269 | NestedEnum value = 3; 270 | } 271 | 272 | enum AnotherEnum { 273 | option allow_alias = true; 274 | 275 | USE = 2; 276 | OK = 3; 277 | FINE = 3; 278 | } 279 | 280 | service ChannelChanger { 281 | rpc Next(stream NextRequest) returns (Channel); 282 | rpc Previous(PreviousRequest) returns (stream Channel); 283 | } 284 | ` 285 | 286 | const noRemoveReservedFieldsNestedMessageProto = `syntax = "proto3"; 287 | package test; 288 | 289 | message Channel { 290 | reserved 44, 101, 103 to 110; 291 | reserved "no_more", "goodbye"; 292 | int64 id = 1; 293 | string name = 2; 294 | string description = 3; 295 | string foo = 4; 296 | bool bar = 5; 297 | 298 | message Nested { 299 | reserved 1,2; 300 | string id = 3; 301 | } 302 | } 303 | ` 304 | 305 | const removeReservedFieldsNestedMessageProto = `syntax = "proto3"; 306 | package test; 307 | 308 | message Channel { 309 | reserved 44, 101, 103 to 110; 310 | reserved "no_more", "goodbye"; 311 | int64 id = 1; 312 | string name = 2; 313 | string description = 3; 314 | string foo = 4; 315 | bool bar = 5; 316 | 317 | message Nested { 318 | reserved 1; 319 | string id = 3; 320 | } 321 | } 322 | ` 323 | 324 | const noChangeFieldIDsProto = `syntax = "proto3"; 325 | package test; 326 | 327 | message Channel { 328 | int64 id = 1; 329 | string name = 2; 330 | string description = 3; 331 | string foo = 4; 332 | bool bar = 5; 333 | 334 | message InChannel { 335 | string name = 1; 336 | book is_active = 2; 337 | } 338 | } 339 | 340 | message NextRequest { 341 | map a_map = 1; 342 | } 343 | 344 | message PreviousRequest { 345 | reserved 4; 346 | reserved "no_use"; 347 | oneof test_oneof { 348 | int64 id = 1; 349 | bool is_active = 2; 350 | } 351 | 352 | enum NestedEnum { 353 | option allow_alias = true; 354 | 355 | ONE = 1; 356 | UNO = 1; 357 | TWO = 2; 358 | THREE = 3; 359 | } 360 | 361 | NestedEnum value = 3; 362 | } 363 | 364 | enum AnotherEnum { 365 | ABC = 1; 366 | DEF = 2; 367 | } 368 | 369 | service ChannelChanger { 370 | rpc Next(stream NextRequest) returns (Channel); 371 | rpc Previous(PreviousRequest) returns (stream Channel); 372 | } 373 | ` 374 | 375 | const changeFieldIDsProto = `syntax = "proto3"; 376 | package test; 377 | 378 | message Channel { 379 | int64 id = 1; 380 | string name = 2; 381 | string description = 3; 382 | string foo = 4443; 383 | bool bar = 59; 384 | 385 | message InChannel { 386 | string name = 444; 387 | book is_active = 2; 388 | } 389 | } 390 | 391 | message NextRequest { 392 | map a_map = 2; 393 | } 394 | 395 | message PreviousRequest { 396 | reserved 4; 397 | reserved "no_use"; 398 | oneof test_oneof { 399 | int64 id = 11; 400 | bool is_active = 32; 401 | } 402 | 403 | enum NestedEnum { 404 | option allow_alias = true; 405 | 406 | ONE = 1; 407 | UNO = 7; 408 | TWO = 2; 409 | THREE = 3; 410 | } 411 | 412 | NestedEnum value = 3; 413 | } 414 | 415 | enum AnotherEnum { 416 | ABC = 1; 417 | DEF = 99; 418 | } 419 | 420 | service ChannelChanger { 421 | rpc Next(stream NextRequest) returns (Channel); 422 | rpc Previous(PreviousRequest) returns (stream Channel); 423 | } 424 | ` 425 | 426 | const noChangeFieldIDsNestedMessageProto = `syntax = "proto3"; 427 | package test; 428 | 429 | message Channel { 430 | int64 id = 1; 431 | string name = 2; 432 | string description = 3; 433 | 434 | message Nested { 435 | string foo = 1; 436 | bool bar = 2; 437 | } 438 | }` 439 | 440 | const changeFieldIDsNestedMessageProto = `syntax = "proto3"; 441 | package test; 442 | 443 | message Channel { 444 | int64 id = 1; 445 | string name = 2; 446 | string description = 3; 447 | 448 | message Nested { 449 | string foo = 11; 450 | bool bar = 22; 451 | } 452 | }` 453 | 454 | const noChangingFieldTypesProto = `syntax = "proto3"; 455 | package test; 456 | 457 | message Channel { 458 | int64 id = 1; 459 | string name = 2; 460 | string description = 3; 461 | string foo = 4; 462 | bool bar = 5; 463 | } 464 | 465 | message Request { 466 | .example.snth.Field field2 = 1; 467 | } 468 | 469 | 470 | message NextRequest { 471 | string name = 1; 472 | map a_map = 3; 473 | } 474 | 475 | message PreviousRequest { 476 | oneof test_oneof { 477 | int64 id = 1; 478 | bool is_active = 2; 479 | } 480 | } 481 | 482 | service ChannelChanger { 483 | rpc Next(stream NextRequest) returns (Channel); 484 | rpc Previous(PreviousRequest) returns (stream Channel); 485 | } 486 | ` 487 | 488 | const changingFieldTypesProto = `syntax = "proto3"; 489 | package test; 490 | 491 | message Channel { 492 | int32 id = 1; 493 | bool name = 2; 494 | string description = 3; 495 | string foo = 4; 496 | repeated bool bar = 5; 497 | } 498 | 499 | message Request { 500 | .example.notSnth.NotField field2 = 1; 501 | } 502 | 503 | message NextRequest { 504 | string name = 1; 505 | map a_map = 3; 506 | } 507 | 508 | message PreviousRequest { 509 | oneof test_oneof { 510 | int32 id = 1; 511 | bool is_active = 2; 512 | } 513 | } 514 | 515 | service ChannelChanger { 516 | rpc Next(stream NextRequest) returns (Channel); 517 | rpc Previous(PreviousRequest) returns (stream Channel); 518 | } 519 | ` 520 | 521 | const noChangingFieldTypesNestedMessageProto = `syntax = "proto3"; 522 | package test; 523 | 524 | message Channel { 525 | int64 id = 1; 526 | string name = 2; 527 | string description = 3; 528 | message Nested { 529 | string foo = 1; 530 | bool bar = 2; 531 | map n_map = 3; 532 | } 533 | } 534 | ` 535 | 536 | const changingFieldTypesNestedMessageProto = `syntax = "proto3"; 537 | package test; 538 | 539 | message Channel { 540 | int64 id = 1; 541 | string name = 2; 542 | string description = 3; 543 | message Nested { 544 | int64 foo = 1; 545 | bool bar = 2; 546 | map n_map = 3; 547 | } 548 | } 549 | ` 550 | 551 | const noChangingFieldNamesProto = `syntax = "proto3"; 552 | package test; 553 | 554 | message Channel { 555 | int64 id = 1; 556 | string name = 2; 557 | string description = 3; 558 | string foo = 4; 559 | bool bar = 5; 560 | } 561 | 562 | message NextRequest { 563 | map a_map = 1; 564 | } 565 | 566 | message PreviousRequest { 567 | oneof test_oneof { 568 | string name = 4; 569 | bool is_active = 9; 570 | } 571 | 572 | enum NestedEnum { 573 | option allow_alias = true; 574 | 575 | ONE = 1; 576 | TWO = 2; 577 | DOS = 2; 578 | } 579 | } 580 | 581 | enum AnotherEnum { 582 | ABC = 1; 583 | DEF = 2; 584 | } 585 | 586 | service ChannelChanger { 587 | rpc Next(stream NextRequest) returns (Channel); 588 | rpc Previous(PreviousRequest) returns (stream Channel); 589 | } 590 | ` 591 | 592 | const changingFieldNamesProto = `syntax = "proto3"; 593 | package test; 594 | 595 | message Channel { 596 | reserved "name", "foo"; 597 | int64 channel_id = 1; 598 | string name_2 = 2; 599 | string description_3 = 3; 600 | string foo_baz = 4; 601 | bool bar = 5; 602 | } 603 | 604 | message NextRequest { 605 | map b_map = 1; 606 | } 607 | 608 | message PreviousRequest { 609 | oneof test_oneof { 610 | string name_2 = 4; 611 | bool is_active = 9; 612 | } 613 | 614 | enum NestedEnum { 615 | option allow_alias = true; 616 | 617 | UNO = 1; 618 | TWO = 2; 619 | DOS = 2; 620 | } 621 | } 622 | 623 | enum AnotherEnum { 624 | ABC = 1; 625 | GHI = 2; 626 | } 627 | 628 | service ChannelChanger { 629 | rpc Next(stream NextRequest) returns (Channel); 630 | rpc Previous(PreviousRequest) returns (stream Channel); 631 | } 632 | ` 633 | 634 | const noChangingFieldNamesNestedMessageProto = `syntax = "proto3"; 635 | package test; 636 | 637 | message Channel { 638 | int64 id = 1; 639 | string name = 2; 640 | string description = 3; 641 | message Nested { 642 | string foo = 1; 643 | bool bar = 2; 644 | } 645 | } 646 | ` 647 | 648 | const changingFieldNamesNestedMessageProto = `syntax = "proto3"; 649 | package test; 650 | 651 | message Channel { 652 | int64 id = 1; 653 | string name = 2; 654 | string description = 3; 655 | message Nested { 656 | string foo_baz = 1; 657 | bool bar = 2; 658 | } 659 | } 660 | ` 661 | 662 | const noRemovingServicesRPCsProto = `syntax = "proto3"; 663 | package test; 664 | 665 | message Channel { 666 | int64 id = 1; 667 | string name = 2; 668 | string description = 3; 669 | string foo = 4; 670 | bool bar = 5; 671 | } 672 | 673 | message NextRequest {} 674 | message PreviousRequest {} 675 | 676 | service ChannelChanger { 677 | rpc Next(stream NextRequest) returns (Channel); 678 | rpc Previous(PreviousRequest) returns (stream Channel); 679 | } 680 | ` 681 | 682 | const removingServicesRPCsProto = `syntax = "proto3"; 683 | package test; 684 | 685 | message Channel { 686 | int64 id = 1; 687 | string name = 2; 688 | string description = 3; 689 | string foo = 4; 690 | bool bar = 5; 691 | } 692 | 693 | message NextRequest {} 694 | message PreviousRequest {} 695 | 696 | service ChannelChanger { 697 | } 698 | ` 699 | 700 | const noChangingRPCSignatureProto = `syntax = "proto3"; 701 | package test; 702 | 703 | message Channel { 704 | int64 id = 1; 705 | string name = 2; 706 | string description = 3; 707 | string foo = 4; 708 | bool bar = 5; 709 | } 710 | 711 | message NextRequest {} 712 | message PreviousRequest {} 713 | 714 | service ChannelChanger { 715 | rpc Next(stream NextRequest) returns (Channel); 716 | rpc Previous(PreviousRequest) returns (stream Channel); 717 | } 718 | ` 719 | 720 | const changingRPCSignatureProto = `syntax = "proto3"; 721 | package test; 722 | 723 | message Channel { 724 | int64 id = 1; 725 | string name = 2; 726 | string description = 3; 727 | string foo = 4; 728 | bool bar = 5; 729 | } 730 | 731 | message NextRequest {} 732 | message PreviousRequest {} 733 | 734 | service ChannelChanger { 735 | rpc Next(NextRequest) returns (ChannelDifferent); 736 | rpc Previous(stream PreviousRequest) returns (stream Channel); 737 | } 738 | ` 739 | 740 | const noRemovingFieldsWithoutReserveProto = `syntax = "proto3"; 741 | package test; 742 | 743 | message Channel { 744 | int64 id = 1; 745 | string name = 2; 746 | string description = 3; 747 | string foo = 4; 748 | bool bar = 5; 749 | 750 | message InChannel { 751 | string name = 1; 752 | int64 id = 2; 753 | bool foo = 3; 754 | } 755 | } 756 | 757 | message NextRequest { 758 | map a_map = 1; 759 | } 760 | 761 | message PreviousRequest { 762 | oneof test_oneof { 763 | int64 id = 1; 764 | bool is_active = 2; 765 | } 766 | 767 | enum NestedEnum { 768 | option allow_alias = true; 769 | 770 | ONE = 1; 771 | UNO = 1; 772 | TWO = 2; 773 | } 774 | 775 | NestEnum value = 4; 776 | } 777 | 778 | enum AnotherEnum { 779 | ABC = 1; 780 | DEF = 2; 781 | } 782 | 783 | service ChannelChanger { 784 | rpc Next(stream NextRequest) returns (Channel); 785 | rpc Previous(PreviousRequest) returns (stream Channel); 786 | } 787 | ` 788 | 789 | const removingFieldsWithoutReserveProto = `syntax = "proto3"; 790 | package test; 791 | 792 | message Channel { 793 | reserved 5; 794 | int64 id = 1; 795 | string name_new = 2; 796 | string description = 3; 797 | string foo = 4; 798 | 799 | message InChannel { 800 | bool foo = 3; 801 | } 802 | } 803 | 804 | message NextRequest { 805 | reserved 1; 806 | } 807 | 808 | message PreviousRequest { 809 | reserved 1; 810 | 811 | enum NestedEnum { 812 | reserved 1; 813 | reserved "ONE"; 814 | option allow_alias = true; 815 | 816 | TWO = 2; 817 | } 818 | 819 | NestEnum value = 4; 820 | } 821 | 822 | enum AnotherEnum { 823 | DEF = 2; 824 | } 825 | 826 | service ChannelChanger { 827 | rpc Next(stream NextRequest) returns (Channel); 828 | rpc Previous(PreviousRequest) returns (stream Channel); 829 | } 830 | ` 831 | 832 | const noRemovingFieldsWithoutReserveNestedMessageProto = `syntax = "proto3"; 833 | package test; 834 | 835 | message Channel { 836 | int64 id = 1; 837 | string name = 2; 838 | string description = 3; 839 | string foo = 4; 840 | bool bar = 5; 841 | 842 | message Nested { 843 | string id = 1; 844 | string name = 2; 845 | } 846 | } 847 | ` 848 | 849 | const removingFieldsWithoutReserveNestedMessageProto = `syntax = "proto3"; 850 | package test; 851 | 852 | message Channel { 853 | int64 id = 1; 854 | string name = 2; 855 | string description = 3; 856 | string foo = 4; 857 | bool bar = 5; 858 | 859 | message Nested { 860 | string name = 2; 861 | } 862 | } 863 | ` 864 | 865 | const noConflictSameNameNestedMessages = `syntax = "proto3"; 866 | package main; 867 | 868 | message A { 869 | message I { 870 | int32 index = 1; 871 | } 872 | 873 | string id = 1; 874 | I i = 2; 875 | } 876 | 877 | message B { 878 | message I { 879 | reserved 2; 880 | int32 index = 1; 881 | } 882 | 883 | string id = 1; 884 | I i = 2; 885 | } 886 | ` 887 | 888 | const shouldConflictNestedMessage = `syntax = "proto3"; 889 | package main; 890 | 891 | message A { 892 | message I { 893 | int32 index = 1; 894 | } 895 | 896 | string id = 1; 897 | I i = 2; 898 | } 899 | 900 | message B { 901 | message I { 902 | int32 index = 1; 903 | string name = 2; 904 | } 905 | 906 | string id = 1; 907 | I i = 2; 908 | } 909 | ` 910 | 911 | const shouldFlagFieldMovedToFromOneofProto = `syntax = "proto3"; 912 | package test; 913 | 914 | message Channel { 915 | oneof test_oneof { 916 | int64 id = 1; 917 | } 918 | string name = 2; 919 | string description = 3; 920 | } 921 | 922 | message NextRequest {} 923 | message PreviousRequest {} 924 | 925 | service ChannelChanger { 926 | rpc Next(stream NextRequest) returns (Channel); 927 | rpc Previous(PreviousRequest) returns (stream Channel); 928 | } 929 | ` 930 | 931 | func TestParseOnReader(t *testing.T) { 932 | r := strings.NewReader(simpleProto) 933 | _, err := Parse("simpleProto", r) 934 | assert.NoError(t, err) 935 | } 936 | 937 | func TestChangingRPCSignature(t *testing.T) { 938 | SetDebug(true) 939 | curLock := parseTestProto(t, noChangingRPCSignatureProto) 940 | updLock := parseTestProto(t, changingRPCSignatureProto) 941 | 942 | warnings, ok := NoChangingRPCSignature(curLock, updLock) 943 | assert.False(t, ok) 944 | assert.Len(t, warnings, 3) 945 | 946 | warnings, ok = NoChangingRPCSignature(updLock, updLock) 947 | assert.True(t, ok) 948 | assert.Len(t, warnings, 0) 949 | } 950 | 951 | func TestRemovingServiceRPCs(t *testing.T) { 952 | SetDebug(true) 953 | curLock := parseTestProto(t, noRemovingServicesRPCsProto) 954 | updLock := parseTestProto(t, removingServicesRPCsProto) 955 | 956 | warnings, ok := NoRemovingRPCs(curLock, updLock) 957 | assert.False(t, ok) 958 | assert.Len(t, warnings, 2) 959 | 960 | warnings, ok = NoRemovingRPCs(updLock, updLock) 961 | assert.True(t, ok) 962 | assert.Len(t, warnings, 0) 963 | } 964 | 965 | func TestChangingFieldNames(t *testing.T) { 966 | SetDebug(true) 967 | curLock := parseTestProto(t, noChangingFieldNamesProto) 968 | updLock := parseTestProto(t, changingFieldNamesProto) 969 | 970 | warnings, ok := NoChangingFieldNames(curLock, updLock) 971 | assert.False(t, ok) 972 | assert.Len(t, warnings, 8) 973 | 974 | warnings, ok = NoChangingFieldNames(updLock, updLock) 975 | assert.True(t, ok) 976 | assert.Len(t, warnings, 0) 977 | } 978 | 979 | func TestChangingFieldNamesNestedMessages(t *testing.T) { 980 | SetDebug(true) 981 | curLock := parseTestProto(t, noChangingFieldNamesNestedMessageProto) 982 | updLock := parseTestProto(t, changingFieldNamesNestedMessageProto) 983 | 984 | warnings, ok := NoChangingFieldNames(curLock, updLock) 985 | assert.False(t, ok) 986 | assert.Len(t, warnings, 1) 987 | } 988 | 989 | func TestChangingFieldTypes(t *testing.T) { 990 | SetDebug(true) 991 | curLock := parseTestProto(t, noChangingFieldTypesProto) 992 | updLock := parseTestProto(t, changingFieldTypesProto) 993 | 994 | warnings, ok := NoChangingFieldTypes(curLock, updLock) 995 | assert.False(t, ok) 996 | assert.Len(t, warnings, 7) 997 | 998 | warnings, ok = NoChangingFieldTypes(updLock, updLock) 999 | assert.True(t, ok) 1000 | assert.Len(t, warnings, 0) 1001 | } 1002 | 1003 | func TestChangingFieldTypesNestedMessages(t *testing.T) { 1004 | SetDebug(true) 1005 | curLock := parseTestProto(t, noChangingFieldTypesNestedMessageProto) 1006 | updLock := parseTestProto(t, changingFieldTypesNestedMessageProto) 1007 | 1008 | warnings, ok := NoChangingFieldTypes(curLock, updLock) 1009 | assert.False(t, ok) 1010 | assert.Len(t, warnings, 3) 1011 | } 1012 | 1013 | func TestUsingReservedFields(t *testing.T) { 1014 | SetDebug(true) 1015 | curLock := parseTestProto(t, noUsingReservedFieldsProto) 1016 | updLock := parseTestProto(t, usingReservedFieldsProto) 1017 | 1018 | warnings, ok := NoUsingReservedFields(curLock, updLock) 1019 | assert.False(t, ok) 1020 | assert.Len(t, warnings, 17) 1021 | 1022 | warnings, ok = NoUsingReservedFields(updLock, updLock) 1023 | assert.True(t, ok) 1024 | assert.Len(t, warnings, 0) 1025 | } 1026 | 1027 | func TestRemovingReservedFields(t *testing.T) { 1028 | SetDebug(true) 1029 | curLock := parseTestProto(t, noRemoveReservedFieldsProto) 1030 | updLock := parseTestProto(t, removeReservedFieldsProto) 1031 | 1032 | warnings, ok := NoRemovingReservedFields(curLock, updLock) 1033 | assert.False(t, ok) 1034 | assert.Len(t, warnings, 15) 1035 | 1036 | warnings, ok = NoRemovingReservedFields(updLock, updLock) 1037 | assert.True(t, ok) 1038 | assert.Len(t, warnings, 0) 1039 | } 1040 | 1041 | func TestRemovingReservedFieldsNestedMessages(t *testing.T) { 1042 | SetDebug(true) 1043 | curLock := parseTestProto(t, noRemoveReservedFieldsNestedMessageProto) 1044 | updLock := parseTestProto(t, removeReservedFieldsNestedMessageProto) 1045 | 1046 | warnings, ok := NoRemovingReservedFields(curLock, updLock) 1047 | assert.False(t, ok) 1048 | assert.Len(t, warnings, 1) 1049 | 1050 | warnings, ok = NoRemovingReservedFields(updLock, updLock) 1051 | assert.True(t, ok) 1052 | assert.Len(t, warnings, 0) 1053 | } 1054 | 1055 | func TestChangingFieldIDs(t *testing.T) { 1056 | SetDebug(true) 1057 | curLock := parseTestProto(t, noChangeFieldIDsProto) 1058 | updLock := parseTestProto(t, changeFieldIDsProto) 1059 | 1060 | warnings, ok := NoChangingFieldIDs(curLock, updLock) 1061 | assert.False(t, ok) 1062 | assert.Len(t, warnings, 8) 1063 | 1064 | warnings, ok = NoChangingFieldIDs(updLock, updLock) 1065 | assert.True(t, ok) 1066 | assert.Len(t, warnings, 0) 1067 | } 1068 | 1069 | func TestChangingFieldIdsNestedMessages(t *testing.T) { 1070 | SetDebug(true) 1071 | curLock := parseTestProto(t, noChangeFieldIDsNestedMessageProto) 1072 | updLock := parseTestProto(t, changeFieldIDsNestedMessageProto) 1073 | 1074 | warnings, ok := NoChangingFieldIDs(curLock, updLock) 1075 | assert.False(t, ok) 1076 | assert.Len(t, warnings, 2) 1077 | } 1078 | 1079 | func TestRemovingFieldsWithoutReserve(t *testing.T) { 1080 | SetDebug(true) 1081 | curLock := parseTestProto(t, noRemovingFieldsWithoutReserveProto) 1082 | updLock := parseTestProto(t, removingFieldsWithoutReserveProto) 1083 | 1084 | warnings, ok := NoRemovingFieldsWithoutReserve(curLock, updLock) 1085 | assert.False(t, ok) 1086 | assert.Len(t, warnings, 13) 1087 | 1088 | warnings, ok = NoRemovingFieldsWithoutReserve(updLock, updLock) 1089 | assert.True(t, ok) 1090 | assert.Len(t, warnings, 0) 1091 | } 1092 | 1093 | func TestRemovingFieldsWithoutReserveNestedMessages(t *testing.T) { 1094 | SetDebug(true) 1095 | curLock := parseTestProto(t, noRemovingFieldsWithoutReserveNestedMessageProto) 1096 | updLock := parseTestProto(t, removingFieldsWithoutReserveNestedMessageProto) 1097 | 1098 | warnings, ok := NoRemovingFieldsWithoutReserve(curLock, updLock) 1099 | assert.False(t, ok) 1100 | assert.Len(t, warnings, 2) 1101 | } 1102 | 1103 | func TestNoConflictSameNameNestedMessages(t *testing.T) { 1104 | SetDebug(true) 1105 | curLock := parseTestProto(t, noConflictSameNameNestedMessages) 1106 | 1107 | warnings, ok := NoUsingReservedFields(curLock, curLock) 1108 | assert.True(t, ok) 1109 | assert.Len(t, warnings, 0) 1110 | } 1111 | 1112 | func TestShouldConflictReusingFieldsNestedMessages(t *testing.T) { 1113 | SetDebug(true) 1114 | curLock := parseTestProto(t, noConflictSameNameNestedMessages) 1115 | updLock := parseTestProto(t, shouldConflictNestedMessage) 1116 | 1117 | warnings, ok := NoUsingReservedFields(curLock, updLock) 1118 | assert.False(t, ok) 1119 | assert.Len(t, warnings, 1) 1120 | } 1121 | 1122 | func TestMovingFieldIntoOrOutOfOneof(t *testing.T) { 1123 | SetDebug(true) 1124 | curLock := parseTestProto(t, simpleProto) 1125 | updLock := parseTestProto(t, shouldFlagFieldMovedToFromOneofProto) 1126 | 1127 | warnings, ok := NoMovingExistingFieldsIntoOrOutOfOneof(curLock, updLock) 1128 | assert.False(t, ok) 1129 | assert.Len(t, warnings, 1) 1130 | assert.Equal(t, "\"id\" was moved into oneof \"test_oneof\"", warnings[0].Message) 1131 | 1132 | warnings, ok = NoMovingExistingFieldsIntoOrOutOfOneof(updLock, updLock) 1133 | assert.True(t, ok) 1134 | assert.Len(t, warnings, 0) 1135 | 1136 | warnings, ok = NoMovingExistingFieldsIntoOrOutOfOneof(updLock, curLock) 1137 | assert.False(t, ok) 1138 | assert.Len(t, warnings, 1) 1139 | assert.Equal(t, "\"id\" was moved out of oneof \"test_oneof\"", warnings[0].Message) 1140 | } 1141 | 1142 | func parseTestProto(t *testing.T, proto string) Protolock { 1143 | r := strings.NewReader(proto) 1144 | entry, err := Parse("proto", r) 1145 | assert.NoError(t, err) 1146 | return Protolock{ 1147 | Definitions: []Definition{ 1148 | { 1149 | Filepath: Protopath("memory/io.Reader"), 1150 | Def: entry, 1151 | }, 1152 | }, 1153 | } 1154 | } 1155 | -------------------------------------------------------------------------------- /status.go: -------------------------------------------------------------------------------- 1 | package protolock 2 | 3 | import ( 4 | "errors" 5 | "os" 6 | ) 7 | 8 | // ErrOutOfDate indicates that the locked definitions are ahead or behind of 9 | // the source proto definitions within the tree. 10 | var ErrOutOfDate = errors.New("proto.lock file is not up-to-date with source") 11 | 12 | // Status will report on any issues encountered when comparing the updated tree 13 | // of parsed proto files and the current proto.lock file. 14 | func Status(cfg Config) (*Report, error) { 15 | updated, err := getUpdatedLock(cfg) 16 | if err != nil { 17 | return nil, err 18 | } 19 | 20 | lockFile, err := openLockFile(cfg) 21 | if err != nil { 22 | if os.IsNotExist(err) { 23 | msg := `no "proto.lock" file found, first run "init"` 24 | return nil, errors.New(msg) 25 | } 26 | return nil, err 27 | } 28 | defer lockFile.Close() 29 | 30 | current, err := FromReader(lockFile) 31 | if err != nil { 32 | return nil, err 33 | } 34 | 35 | report, err := Compare(current, *updated) 36 | if err != nil { 37 | return report, err 38 | } 39 | 40 | // only check if current and updated are equal if up-to-date flag is true 41 | if cfg.UpToDate && !current.Equal(updated) { 42 | err = ErrOutOfDate 43 | } 44 | return report, err 45 | } 46 | -------------------------------------------------------------------------------- /testdata/getProtoFiles/directory.proto/test.non-proto: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nilslice/protolock/ac27b429a112f822ea45a16082e20ac2b86f0c4b/testdata/getProtoFiles/directory.proto/test.non-proto -------------------------------------------------------------------------------- /testdata/getProtoFiles/exclude.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | package exclude; 3 | 4 | message Exclude { 5 | string name = 1; 6 | } 7 | -------------------------------------------------------------------------------- /testdata/getProtoFiles/exclude/test.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | package exclude; 3 | 4 | message Test { 5 | string name = 1; 6 | } 7 | -------------------------------------------------------------------------------- /testdata/getProtoFiles/include/exclude.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | package exclude; 3 | 4 | message Exclude { 5 | string name = 1; 6 | } 7 | -------------------------------------------------------------------------------- /testdata/getProtoFiles/include/include.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | package include; 3 | 4 | message Include { 5 | string name = 1; 6 | } 7 | -------------------------------------------------------------------------------- /testdata/imports_options.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | package test; 3 | 4 | import "google/protobuf/descriptor.proto"; 5 | import "testdata/test.proto"; 6 | 7 | message Channel { 8 | option (ext.persisted) = { opt1: true opt2: false }; 9 | int64 id = 1; 10 | string name = 2; 11 | string description = 3; 12 | } 13 | 14 | message Channel2 { 15 | option (ext.persisted) = true; 16 | int64 id = 1; 17 | string name = 2 [(personal) = true, (owner) = 'test']; 18 | string description = 3 [(custom_options_commas) = { personal: true, internal: false, owner: "some owner" }]; 19 | map map = 4 [(personal) = true]; 20 | string address = 5 [(custom_options) = { personal: true internal: false owner: "some owner", arr: [1.2, 3.4], map: { a:b, c:d } }]; 21 | } 22 | 23 | enum TestEnumOption { 24 | reserved 2; 25 | option allow_alias = true; 26 | FIRST = 0; 27 | SECOND = 1; 28 | SEGUNDO = 3 [(my_enum_value_option) = 321]; 29 | } 30 | 31 | message FieldOptions { 32 | bool personal = 1; 33 | bool internal = 2; 34 | string owner = 3; 35 | } 36 | 37 | extend google.protobuf.FieldOptions { 38 | FieldOptions custom_options = 50000; 39 | } -------------------------------------------------------------------------------- /testdata/test.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | package dataset; 3 | 4 | option java_multiple_files = true; 5 | option java_package = "test.java.package"; 6 | option java_outer_classname = "TestClass"; 7 | 8 | service TestService { 9 | rpc TestRpc(TestRequest) returns (TestResponse) { 10 | option (test_option) = "option_value"; 11 | option (test_option_2) = "option_value_3"; 12 | } 13 | } 14 | 15 | message TestRequest {} 16 | message TestResponse {} 17 | 18 | message Channel { 19 | reserved 6, 8 to 11; 20 | int64 id = 1; 21 | string name = 2; 22 | string description = 3; 23 | string foo = 4; 24 | int32 age = 5; 25 | int32 newnew = 101; 26 | 27 | message A { int32 id = 1; } 28 | 29 | A msg = 44; 30 | } 31 | 32 | message Display { 33 | reserved "a_map", 'single_quoted'; 34 | reserved 3; 35 | int32 width = 1; 36 | int32 height = 2; 37 | 38 | message A { 39 | reserved 2; 40 | int64 id = 1; 41 | } 42 | 43 | A msg = 44; 44 | 45 | map b_map = 4; 46 | } 47 | 48 | enum TestEnum { 49 | reserved 2; 50 | option allow_alias = true; 51 | FIRST = 0; 52 | SECOND = 1; 53 | SEGUNDO = 1; 54 | } 55 | 56 | message ContainsEnum { 57 | int32 id = 1; 58 | 59 | enum NestedEnum { 60 | reserved 101; 61 | reserved "DEPTH"; 62 | 63 | ABC = 1; 64 | DEF = 2; 65 | } 66 | 67 | NestedEnum value = 2; 68 | } 69 | 70 | // @protolock:skip 71 | message NextRequest {} 72 | 73 | message PreviousRequest { 74 | oneof test_oneof { 75 | string name = 4; 76 | bool is_active = 9; 77 | } 78 | } 79 | 80 | service ChannelChanger { 81 | rpc Next(stream NextRequest) returns (Channel); 82 | rpc Previous(PreviousRequest) returns (stream Channel); 83 | } 84 | 85 | message FloatIn { float val = 1 [(validate.rules).float = {in: [4.56, 7.89]}]; } -------------------------------------------------------------------------------- /uptodate.go: -------------------------------------------------------------------------------- 1 | package protolock 2 | 3 | import ( 4 | "reflect" 5 | ) 6 | 7 | // Check whether one lockfile is equal to another. 8 | func (p *Protolock) Equal(q *Protolock) bool { 9 | // Check whether the two lockfiles have the same list of 10 | // definitions, ignoring order. 11 | return isPermutation(p.Definitions, q.Definitions, equalDefinitions) 12 | } 13 | 14 | // Check whether two slices are equal, ignoring ordering. 15 | // Uses the provided comparator function to determine equality. 16 | func isPermutation(as, bs interface{}, cmp func(x, y interface{}) bool) bool { 17 | aKind := reflect.TypeOf(as).Kind() 18 | bKind := reflect.TypeOf(bs).Kind() 19 | if aKind != reflect.Array && aKind != reflect.Slice { 20 | panic("isPermutation was given an argument that isn't an array or slice") 21 | } 22 | if bKind != reflect.Array && bKind != reflect.Slice { 23 | panic("isPermutation was given an argument that isn't an array or slice") 24 | } 25 | 26 | // Get the lengths of the slices via reflection 27 | aList := reflect.ValueOf(as) 28 | bList := reflect.ValueOf(bs) 29 | aLen := aList.Len() 30 | bLen := bList.Len() 31 | 32 | // Slices of different lengths are trivially inequal 33 | if aLen != bLen { 34 | return false 35 | } 36 | 37 | // Empty slices are trivially equal 38 | if aLen == 0 { 39 | return true 40 | } 41 | 42 | // Try to match each element in A to an element in B 43 | // Keep track of which elements in B we've already matched 44 | used := make([]bool, bLen) 45 | for i := 0; i < aLen; i++ { 46 | current := aList.Index(i).Interface() 47 | found := false 48 | for j := 0; j < bLen; j++ { 49 | if used[j] { 50 | continue 51 | } 52 | candidate := bList.Index(j).Interface() 53 | 54 | if cmp(current, candidate) { 55 | // Found a match, mark it as used 56 | found = true 57 | used[j] = true 58 | break 59 | } 60 | } 61 | 62 | if !found { 63 | // Nothing in B (that was not already matched) 64 | // matches the current element, slices are 65 | // inequal 66 | return false 67 | } 68 | } 69 | 70 | return true 71 | } 72 | 73 | // Helper functions to determine equality of subparts of a lockfile. 74 | // Some functions take interface{} because they're used with 75 | // isPermutation. 76 | 77 | func equalDefinitions(i, j interface{}) bool { 78 | a := i.(Definition) 79 | b := j.(Definition) 80 | return a.Filepath == b.Filepath && equalEntries(a.Def, b.Def) 81 | } 82 | 83 | func equalEntries(a, b Entry) bool { 84 | if a.Package != b.Package { 85 | return false 86 | } 87 | if !isPermutation(a.Enums, b.Enums, equalEnums) { 88 | return false 89 | } 90 | if !isPermutation(a.Messages, b.Messages, equalMessages) { 91 | return false 92 | } 93 | if !isPermutation(a.Services, b.Services, equalServices) { 94 | return false 95 | } 96 | if !isPermutation(a.Imports, b.Imports, equalImports) { 97 | return false 98 | } 99 | return isPermutation(a.Options, b.Options, equalOptions) 100 | } 101 | 102 | func equalImports(i, j interface{}) bool { 103 | // Struct has only primitive fields and no slice fields, fall 104 | // back to default equality 105 | a := i.(Import) 106 | b := j.(Import) 107 | return a == b 108 | } 109 | 110 | func equalPackage(i, j interface{}) bool { 111 | // Struct has only primitive fields and no slice fields, fall 112 | // back to default equality 113 | a := i.(Package) 114 | b := j.(Package) 115 | return a == b 116 | } 117 | 118 | func equalOptions(i, j interface{}) bool { 119 | a := i.(Option) 120 | b := j.(Option) 121 | 122 | if a.Name != b.Name || a.Value != b.Value { 123 | return false 124 | } 125 | return isPermutation(a.Aggregated, b.Aggregated, equalOptions) 126 | } 127 | 128 | func equalMessages(i, j interface{}) bool { 129 | a := i.(Message) 130 | b := j.(Message) 131 | 132 | if a.Name != b.Name || a.Filepath != b.Filepath { 133 | return false 134 | } 135 | if !isPermutation(a.Fields, b.Fields, equalFields) { 136 | return false 137 | } 138 | if !isPermutation(a.Maps, b.Maps, equalMaps) { 139 | return false 140 | } 141 | if !isPermutation(a.ReservedIDs, b.ReservedIDs, equalPrimitives) { 142 | return false 143 | } 144 | if !isPermutation(a.ReservedNames, b.ReservedNames, equalPrimitives) { 145 | return false 146 | } 147 | if !isPermutation(a.Messages, b.Messages, equalMessages) { 148 | return false 149 | } 150 | return isPermutation(a.Options, b.Options, equalOptions) 151 | } 152 | 153 | func equalEnumFields(i, j interface{}) bool { 154 | a := i.(EnumField) 155 | b := j.(EnumField) 156 | 157 | if a.Name != b.Name || a.Integer != b.Integer { 158 | return false 159 | } 160 | return isPermutation(a.Options, b.Options, equalOptions) 161 | } 162 | 163 | func equalEnums(i, j interface{}) bool { 164 | a := i.(Enum) 165 | b := j.(Enum) 166 | 167 | if a.Name != b.Name || a.AllowAlias != b.AllowAlias { 168 | return false 169 | } 170 | if !isPermutation(a.ReservedIDs, b.ReservedIDs, equalPrimitives) { 171 | return false 172 | } 173 | if !isPermutation(a.ReservedNames, b.ReservedNames, equalPrimitives) { 174 | return false 175 | } 176 | return isPermutation(a.EnumFields, b.EnumFields, equalEnumFields) 177 | } 178 | 179 | func equalMaps(i, j interface{}) bool { 180 | a := i.(Map) 181 | b := j.(Map) 182 | 183 | return a.KeyType == b.KeyType && equalFields(a.Field, b.Field) 184 | } 185 | 186 | func equalFields(i, j interface{}) bool { 187 | a := i.(Field) 188 | b := j.(Field) 189 | 190 | if a.ID != b.ID || a.Name != b.Name { 191 | return false 192 | } 193 | if a.Type != b.Type || a.IsRepeated != b.IsRepeated { 194 | return false 195 | } 196 | return isPermutation(a.Options, b.Options, equalOptions) 197 | } 198 | 199 | func equalServices(i, j interface{}) bool { 200 | a := i.(Service) 201 | b := j.(Service) 202 | 203 | if a.Name != b.Name || a.Filepath != b.Filepath { 204 | return false 205 | } 206 | return isPermutation(a.RPCs, b.RPCs, equalRPCs) 207 | } 208 | 209 | func equalRPCs(i, j interface{}) bool { 210 | a := i.(RPC) 211 | b := j.(RPC) 212 | 213 | if a.Name != b.Name || a.InType != b.InType || a.OutType != b.OutType { 214 | return false 215 | } 216 | if a.InStreamed != b.InStreamed || a.OutStreamed != b.OutStreamed { 217 | return false 218 | } 219 | 220 | return isPermutation(a.Options, b.Options, equalOptions) 221 | } 222 | 223 | // Helper to compare primitive types in isPermutation 224 | func equalPrimitives(i, j interface{}) bool { 225 | return i == j 226 | } 227 | -------------------------------------------------------------------------------- /uptodate_test.go: -------------------------------------------------------------------------------- 1 | package protolock 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestIsPermutation(t *testing.T) { 10 | assert := assert.New(t) 11 | assert.True(isPermutation([]int{1, 2}, []int{2, 1}, equalPrimitives)) 12 | assert.True(isPermutation([]int{}, []int{}, equalPrimitives)) 13 | assert.False(isPermutation([]int{1, 2}, []int{2, 2}, equalPrimitives)) 14 | assert.False(isPermutation([]int{1, 2}, []int{2, 1, 2}, equalPrimitives)) 15 | assert.False(isPermutation([]int{1, 2}, []int{}, equalPrimitives)) 16 | assert.Panics(func() { 17 | isPermutation(1, 2, equalPrimitives) 18 | }) 19 | } 20 | --------------------------------------------------------------------------------