a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
215 lines
6.5 KiB
Go
215 lines
6.5 KiB
Go
package contracts
|
|
|
|
import (
|
|
"regexp"
|
|
"strings"
|
|
)
|
|
|
|
// goStructBodyRe locates the body of a `type <Name> struct { ... }`
|
|
// declaration starting from start_line. We use it only as a quick
|
|
// shortcut when end_line isn't recorded — normally the parser gives
|
|
// us an accurate span and we slice that directly.
|
|
var goStructOpenRe = regexp.MustCompile(`(?s)type\s+\w+\s+struct\s*\{`)
|
|
|
|
// goFieldRe parses one Go struct field line. Capture groups:
|
|
//
|
|
// 1: field name (or comma-separated names)
|
|
// 2: type expression (with optional leading * / [] / map[])
|
|
// 3: raw tag body including backticks when present
|
|
//
|
|
// Embedded fields (`sync.Mutex`, `io.Reader`) don't match this regex;
|
|
// we skip them by checking that we got two non-empty groups.
|
|
var goFieldRe = regexp.MustCompile(
|
|
"^\\s*([A-Z][A-Za-z0-9_, ]*?)\\s+" + // 1: field name(s) — exported only
|
|
"(\\*?(?:\\[\\]|map\\[[^\\]]+\\])?[A-Za-z_][\\w.\\[\\]*]*)" + // 2: type expr
|
|
"(?:\\s+`([^`]*)`)?\\s*(?://\\s*(.*))?$", // 3: tag, 4: comment
|
|
)
|
|
|
|
// extractGoShape reads a Go `type X struct { ... }` and returns its
|
|
// exported field list. Non-exported fields (lower-case first letter)
|
|
// are dropped because they never appear on the wire. Embedded fields
|
|
// are skipped — they require cross-file resolution we don't do here.
|
|
func extractGoShape(src []byte, startLine, endLine int) *Shape {
|
|
body := sliceBody(src, startLine, endLine)
|
|
if body == "" {
|
|
return nil
|
|
}
|
|
// Normalise: find the opening brace. Anything before it (package
|
|
// docs, `type` keyword, the name) is noise.
|
|
openIdx := strings.Index(body, "{")
|
|
if openIdx < 0 {
|
|
// No opening brace in the slice — fall back to brace walk so
|
|
// types whose end_line == start_line still produce a shape.
|
|
body = braceBody(src, startLine, 300)
|
|
openIdx = strings.Index(body, "{")
|
|
if openIdx < 0 {
|
|
return nil
|
|
}
|
|
}
|
|
// Balance braces to find the close that matches.
|
|
depth := 0
|
|
closeIdx := -1
|
|
for i := openIdx; i < len(body); i++ {
|
|
switch body[i] {
|
|
case '{':
|
|
depth++
|
|
case '}':
|
|
depth--
|
|
if depth == 0 {
|
|
closeIdx = i
|
|
}
|
|
}
|
|
if closeIdx >= 0 {
|
|
break
|
|
}
|
|
}
|
|
if closeIdx < 0 {
|
|
return nil
|
|
}
|
|
inner := body[openIdx+1 : closeIdx]
|
|
|
|
shape := &Shape{Kind: "struct"}
|
|
// Track brace depth across the inner body so we don't hoist
|
|
// fields of nested anonymous structs into the parent shape.
|
|
// Lines are scanned at depth 0 (= immediately inside the outer
|
|
// struct's `{`); when an inline `Stats struct { ... }` opens,
|
|
// depth goes to 1 and child lines are suppressed until the
|
|
// closing `}` returns depth to 0. Without this, a parent that
|
|
// happens to share field names with its inline anonymous
|
|
// substruct (e.g. dashboardSnapshot's outer `Repos`/`Caveats`
|
|
// shadowed by Stats.Repos/Stats.Caveats) produces duplicate
|
|
// field names in the shape — the dashboard then renders two
|
|
// React children with the same key.
|
|
depth = 0
|
|
for _, rawLine := range strings.Split(inner, "\n") {
|
|
line := strings.TrimSpace(rawLine)
|
|
if line == "" || strings.HasPrefix(line, "//") {
|
|
updateGoBraceDepth(line, &depth)
|
|
continue
|
|
}
|
|
if depth == 0 {
|
|
emitGoStructField(line, shape)
|
|
}
|
|
updateGoBraceDepth(line, &depth)
|
|
}
|
|
if len(shape.Fields) == 0 {
|
|
return nil
|
|
}
|
|
return shape
|
|
}
|
|
|
|
// updateGoBraceDepth adjusts the running brace depth from one line
|
|
// of source. Quoted strings and runes don't carry depth-changing
|
|
// braces in well-formed Go struct definitions, so a naive count
|
|
// suffices for the lines we care about. A `//` comment short-circuits
|
|
// the rest of the line.
|
|
func updateGoBraceDepth(line string, depth *int) {
|
|
for i := 0; i < len(line); i++ {
|
|
switch line[i] {
|
|
case '{':
|
|
*depth++
|
|
case '}':
|
|
if *depth > 0 {
|
|
*depth--
|
|
}
|
|
case '/':
|
|
if i+1 < len(line) && line[i+1] == '/' {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// emitGoStructField runs goFieldRe over one line and appends every
|
|
// resulting field (a single decl can declare multiple comma-separated
|
|
// names) to shape.
|
|
func emitGoStructField(line string, shape *Shape) {
|
|
m := goFieldRe.FindStringSubmatch(line)
|
|
if m == nil {
|
|
return
|
|
}
|
|
names := strings.Split(m[1], ",")
|
|
typeExpr := strings.TrimSpace(m[2])
|
|
tag := m[3]
|
|
comment := ""
|
|
if len(m) > 4 {
|
|
comment = truncateComment(m[4])
|
|
}
|
|
jsonTag, required, repeated, effectiveType := goFieldMetadata(typeExpr, tag)
|
|
for _, rawName := range names {
|
|
name := strings.TrimSpace(rawName)
|
|
if name == "" || !isExportedGo(name) {
|
|
continue
|
|
}
|
|
wireName := name
|
|
if jsonTag != "" && jsonTag != "-" {
|
|
wireName = jsonTag
|
|
}
|
|
shape.Fields = append(shape.Fields, ShapeField{
|
|
Name: wireName,
|
|
Type: effectiveType,
|
|
JSONTag: tag,
|
|
Required: required,
|
|
Repeated: repeated,
|
|
Comment: comment,
|
|
})
|
|
}
|
|
}
|
|
|
|
// goFieldMetadata turns a type expression + raw struct tag into the
|
|
// pieces ShapeField exposes.
|
|
//
|
|
// typeExpr — the declared Go type text
|
|
// tag — raw tag body without the surrounding backticks
|
|
//
|
|
// Returns: wire name from json tag (empty = use field name), required
|
|
// (true iff tag has no `omitempty` and the type isn't a pointer),
|
|
// repeated (slice / array type), and the effective type name with
|
|
// leading `*` / `[]` stripped for diffing friendliness.
|
|
func goFieldMetadata(typeExpr, tag string) (jsonName string, required, repeated bool, effective string) {
|
|
// Parse json:"..." out of the raw tag. Tag syntax is
|
|
// `json:"name,opt1,opt2" xml:"..."` with space separators.
|
|
for _, entry := range strings.Fields(tag) {
|
|
if !strings.HasPrefix(entry, "json:") {
|
|
continue
|
|
}
|
|
v := strings.TrimPrefix(entry, "json:")
|
|
v = strings.Trim(v, `"`)
|
|
parts := strings.Split(v, ",")
|
|
jsonName = parts[0]
|
|
for _, p := range parts[1:] {
|
|
if p == "omitempty" {
|
|
// Marked optional.
|
|
required = false
|
|
return jsonName, required, strings.HasPrefix(typeExpr, "[]"), strings.TrimPrefix(strings.TrimPrefix(typeExpr, "*"), "[]")
|
|
}
|
|
}
|
|
break
|
|
}
|
|
|
|
pointer := strings.HasPrefix(typeExpr, "*")
|
|
repeated = strings.HasPrefix(typeExpr, "[]")
|
|
effective = strings.TrimPrefix(strings.TrimPrefix(typeExpr, "*"), "[]")
|
|
// A pointer without `omitempty` is still effectively optional in
|
|
// most Go JSON handling, so we call it not-required. The explicit
|
|
// `omitempty` branch above short-circuits before this; here we
|
|
// infer from type.
|
|
required = !pointer
|
|
if jsonName == "-" {
|
|
// Field is explicitly excluded from JSON.
|
|
jsonName = ""
|
|
required = false
|
|
}
|
|
return jsonName, required, repeated, effective
|
|
}
|
|
|
|
func isExportedGo(name string) bool {
|
|
if name == "" {
|
|
return false
|
|
}
|
|
r := name[0]
|
|
return r >= 'A' && r <= 'Z'
|
|
}
|
|
|
|
var _ = goStructOpenRe // reserved for future "find next struct" helper.
|