// protoc-gen-tarantool-doc generates Markdown reference documentation from
// proto3 .proto files. One Markdown file per input proto, named after the
// proto file (foo.proto -> foo.md). Output mirrors protoc's path layout
// relative to the -I directory.
//
// Usage:
// protoc --tarantool-doc_out=./docs \
// --plugin=./protoc-gen-tarantool-doc foo.proto
//
// Output sections (in order, omitted when empty):
// - Header with package + imports
// - Messages (per-message: leading comment, fields table, oneofs, nested)
// - Enums (per-enum: leading comment, values table)
// - Services (per-service: leading comment, methods table)
//
// Leading comments on messages/enums/fields/services/methods are preserved
// from SourceCodeInfo via protogen.
package main
import (
"fmt"
"io"
"os"
"path"
"sort"
"strings"
"google.golang.org/protobuf/compiler/protogen"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/reflect/protoreflect"
"google.golang.org/protobuf/types/descriptorpb"
"google.golang.org/protobuf/types/pluginpb"
)
func main() {
in, err := io.ReadAll(os.Stdin)
if err != nil {
fail("read stdin: %v", err)
}
req := &pluginpb.CodeGeneratorRequest{}
if err := proto.Unmarshal(in, req); err != nil {
fail("parse CodeGeneratorRequest: %v", err)
}
// Inject a synthetic go_package so protogen accepts the input even when
// none of the files declare one (same trick as the codegen plugin).
for _, f := range req.ProtoFile {
if f.Options == nil {
f.Options = &descriptorpb.FileOptions{}
}
if f.Options.GoPackage == nil {
stub := "tarantooldoc_synthetic/" + strings.TrimSuffix(f.GetName(), ".proto")
f.Options.GoPackage = proto.String(stub)
}
}
plugin, err := protogen.Options{}.New(req)
if err != nil {
fail("init protogen: %v", err)
}
plugin.SupportedFeatures = uint64(pluginpb.CodeGeneratorResponse_FEATURE_PROTO3_OPTIONAL)
for _, file := range plugin.Files {
if !file.Generate {
continue
}
writeFile(plugin, file)
}
out, err := proto.Marshal(plugin.Response())
if err != nil {
fail("marshal CodeGeneratorResponse: %v", err)
}
if _, err := os.Stdout.Write(out); err != nil {
fail("write stdout: %v", err)
}
}
func writeFile(plugin *protogen.Plugin, file *protogen.File) {
base := strings.TrimSuffix(path.Base(file.Desc.Path()), ".proto") + ".md"
relDir := path.Dir(file.Desc.Path())
outPath := base
if relDir != "" && relDir != "." {
outPath = path.Join(relDir, base)
}
g := plugin.NewGeneratedFile(outPath, "")
var b strings.Builder
fmt.Fprintf(&b, "# %s\n\n", file.Desc.Path())
if pkg := file.Desc.Package(); pkg != "" {
fmt.Fprintf(&b, "**Package:** `%s`\n\n", pkg)
}
if imports := file.Desc.Imports(); imports.Len() > 0 {
b.WriteString("**Imports:**\n\n")
for i := 0; i < imports.Len(); i++ {
fmt.Fprintf(&b, "- `%s`\n", imports.Get(i).Path())
}
b.WriteString("\n")
}
if len(file.Messages) > 0 {
b.WriteString("## Messages\n\n")
for _, m := range file.Messages {
renderMessage(&b, m, 3)
}
}
if len(file.Enums) > 0 {
b.WriteString("## Enums\n\n")
for _, e := range file.Enums {
renderEnum(&b, e, 3)
}
}
if len(file.Services) > 0 {
b.WriteString("## Services\n\n")
for _, s := range file.Services {
renderService(&b, s)
}
}
g.P(b.String())
}
// ----- rendering helpers ----------------------------------------------------
func heading(level int) string { return strings.Repeat("#", level) + " " }
func cleanComment(c protogen.Comments) string {
// protogen.Comments leaves each line with a leading space and trailing
// newline. Strip these and join with single newlines.
raw := strings.TrimRight(string(c), "\n")
if raw == "" {
return ""
}
lines := strings.Split(raw, "\n")
for i, l := range lines {
lines[i] = strings.TrimSpace(strings.TrimPrefix(l, " "))
}
return strings.Join(lines, " ")
}
// renderMessage emits a section for one message and recurses into nested
// types. `level` controls Markdown heading depth.
func renderMessage(b *strings.Builder, m *protogen.Message, level int) {
// Skip synthetic map-entry messages.
if m.Desc.IsMapEntry() {
return
}
full := string(m.Desc.FullName())
fmt.Fprintf(b, "%s`%s`\n\n", heading(level), full)
if c := cleanComment(m.Comments.Leading); c != "" {
fmt.Fprintf(b, "%s\n\n", c)
}
// Collect oneof groupings to annotate fields.
oneofByField := map[string]string{}
for _, oo := range m.Oneofs {
if oo.Desc.IsSynthetic() {
continue
}
for _, f := range oo.Fields {
oneofByField[string(f.Desc.Name())] = string(oo.Desc.Name())
}
}
if len(m.Fields) > 0 {
b.WriteString("| # | Field | Type | Label | Description |\n")
b.WriteString("|---|-------|------|-------|-------------|\n")
// Display in declaration order.
fields := append([]*protogen.Field(nil), m.Fields...)
sort.SliceStable(fields, func(i, j int) bool {
return fields[i].Desc.Number() < fields[j].Desc.Number()
})
for _, f := range fields {
fmt.Fprintf(b, "| %d | `%s` | %s | %s | %s |\n",
f.Desc.Number(),
f.Desc.Name(),
fieldTypeText(f),
labelText(f, oneofByField[string(f.Desc.Name())]),
inlineComment(f.Comments.Leading))
}
b.WriteString("\n")
}
// Recurse into nested messages and enums under deeper headings.
for _, nm := range m.Messages {
renderMessage(b, nm, level+1)
}
for _, ne := range m.Enums {
renderEnum(b, ne, level+1)
}
}
func renderEnum(b *strings.Builder, e *protogen.Enum, level int) {
fmt.Fprintf(b, "%s`%s`\n\n", heading(level), e.Desc.FullName())
if c := cleanComment(e.Comments.Leading); c != "" {
fmt.Fprintf(b, "%s\n\n", c)
}
b.WriteString("| Value | Name | Description |\n")
b.WriteString("|-------|------|-------------|\n")
for _, v := range e.Values {
fmt.Fprintf(b, "| %d | `%s` | %s |\n",
v.Desc.Number(),
v.Desc.Name(),
inlineComment(v.Comments.Leading))
}
b.WriteString("\n")
}
func renderService(b *strings.Builder, s *protogen.Service) {
fmt.Fprintf(b, "### `%s`\n\n", s.Desc.FullName())
if c := cleanComment(s.Comments.Leading); c != "" {
fmt.Fprintf(b, "%s\n\n", c)
}
if len(s.Methods) == 0 {
return
}
b.WriteString("| Method | Request | Response | Streaming | Description |\n")
b.WriteString("|--------|---------|----------|-----------|-------------|\n")
for _, m := range s.Methods {
streaming := streamingText(m)
fmt.Fprintf(b, "| `%s` | `%s` | `%s` | %s | %s |\n",
m.Desc.Name(),
m.Desc.Input().FullName(),
m.Desc.Output().FullName(),
streaming,
inlineComment(m.Comments.Leading))
}
b.WriteString("\n")
}
// fieldTypeText renders a field's type as a Markdown fragment. Scalars stay
// lowercase; messages/enums become full-name code spans. Map fields render
// as `map<K, V>`.
func fieldTypeText(f *protogen.Field) string {
d := f.Desc
if d.IsMap() {
return fmt.Sprintf("`map<%s, %s>`",
scalarOrName(d.MapKey()),
scalarOrName(d.MapValue()))
}
return "`" + scalarOrName(d) + "`"
}
// scalarOrName resolves a FieldDescriptor's type as either the lowercase
// proto3 scalar name or the full name of the referenced message/enum.
func scalarOrName(d protoreflect.FieldDescriptor) string {
switch d.Kind() {
case protoreflect.MessageKind, protoreflect.GroupKind:
return string(d.Message().FullName())
case protoreflect.EnumKind:
return string(d.Enum().FullName())
}
return d.Kind().String()
}
func labelText(f *protogen.Field, oneof string) string {
parts := []string{}
d := f.Desc
if d.IsList() && !d.IsMap() {
parts = append(parts, "repeated")
}
if d.HasOptionalKeyword() {
parts = append(parts, "optional")
}
if oneof != "" {
parts = append(parts, fmt.Sprintf("oneof `%s`", oneof))
}
if len(parts) == 0 {
return "—"
}
return strings.Join(parts, ", ")
}
func streamingText(m *protogen.Method) string {
cs := m.Desc.IsStreamingClient()
ss := m.Desc.IsStreamingServer()
switch {
case cs && ss:
return "bidi"
case cs:
return "client"
case ss:
return "server"
}
return "unary"
}
// inlineComment squashes a leading comment to a single line for use inside a
// Markdown table cell (newlines and pipes break table rendering). Empty
// comments render as an em-dash to keep the column non-empty.
func inlineComment(c protogen.Comments) string {
s := cleanComment(c)
if s == "" {
return "—"
}
s = strings.ReplaceAll(s, "|", "\\|")
s = strings.ReplaceAll(s, "\n", " ")
return s
}
func fail(format string, args ...any) {
fmt.Fprintf(os.Stderr, "protoc-gen-tarantool-doc: "+format+"\n", args...)
os.Exit(1)
}