package contracts import ( "regexp" "strings" ) // csPropertyRe captures a C# auto-property declaration: // // public string Id { get; set; } // public string? Nickname { get; init; } // [JsonPropertyName("id")] // public required Guid Id { get; set; } // public List Friends { get; set; } = new(); // // The visibility + optional `required` / `readonly` / `static` prefixes // are consumed; capture 1 is the type, capture 2 is the name. var csPropertyRe = regexp.MustCompile( `^\s*(?:public|internal|protected|private)\s+(?:(?:required|readonly|static|virtual|override|abstract)\s+)*([A-Za-z_][\w<>,.?\s]*?)\s+([A-Za-z_]\w*)\s*(?:\{|=>)`, ) // csJsonPropertyRe extracts the wire name from `[JsonPropertyName("x")]` // or the older Newtonsoft `[JsonProperty("x")]`. var csJsonPropertyRe = regexp.MustCompile(`\[(?:JsonPropertyName|JsonProperty)\(\s*"([^"]+)"`) // csJsonIgnoreRe marks a property as excluded from the wire. var csJsonIgnoreRe = regexp.MustCompile(`\[JsonIgnore`) // extractCSharpShape reads a C# class / record body and returns its // properties. Fields are not returned (fields aren't serialised by // System.Text.Json by default; properties are). Records with // positional parameters are captured through their synthesised // properties — most modern DTOs use this form. func extractCSharpShape(src []byte, startLine, endLine int) *Shape { body := sliceBody(src, startLine, endLine) if body == "" { body = braceBody(src, startLine, 400) } // Positional records: `public record UserResp(string Id, string Email);` // Detected separately — no `{` body, all props in the paren group. if recShape := extractCSharpPositionalRecord(body); recShape != nil { return recShape } openIdx := strings.Index(body, "{") if openIdx < 0 { return nil } 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] lines := strings.Split(inner, "\n") shape := &Shape{Kind: "class"} var pendingAttrs []string for _, raw := range lines { line := strings.TrimSpace(raw) if line == "" || strings.HasPrefix(line, "//") || strings.HasPrefix(line, "///") { continue } if strings.HasPrefix(line, "[") { pendingAttrs = append(pendingAttrs, line) continue } m := csPropertyRe.FindStringSubmatch(line) if m == nil { pendingAttrs = nil continue } typeExpr := strings.TrimSpace(m[1]) name := m[2] // Parse attributes. var jsonTag string ignored := false for _, ann := range pendingAttrs { if jm := csJsonPropertyRe.FindStringSubmatch(ann); len(jm) > 1 { jsonTag = jm[1] } if csJsonIgnoreRe.MatchString(ann) { ignored = true } } pendingAttrs = nil if ignored { continue } nullable := strings.HasSuffix(typeExpr, "?") typeExpr = strings.TrimSuffix(typeExpr, "?") repeated := strings.HasPrefix(typeExpr, "List<") || strings.HasPrefix(typeExpr, "IEnumerable<") || strings.HasPrefix(typeExpr, "ICollection<") || strings.HasPrefix(typeExpr, "IReadOnlyList<") || strings.HasSuffix(typeExpr, "[]") wireName := name if jsonTag != "" { wireName = jsonTag } shape.Fields = append(shape.Fields, ShapeField{ Name: wireName, Type: stripGenericsCSharp(typeExpr), JSONTag: jsonTag, Required: !nullable, Repeated: repeated, }) } if len(shape.Fields) == 0 { return nil } return shape } // csRecordHeadRe matches a positional record declaration: // // public record UserResp(string Id, string Email); // // Capture 1 is the parameter list. var csRecordHeadRe = regexp.MustCompile( `\brecord\s+(?:struct\s+)?\w+\s*\(\s*([^)]+)\s*\)`, ) func extractCSharpPositionalRecord(body string) *Shape { m := csRecordHeadRe.FindStringSubmatch(body) if len(m) < 2 { return nil } shape := &Shape{Kind: "class"} for _, p := range splitTopLevelArgsCS(m[1]) { p = strings.TrimSpace(p) if p == "" { continue } fields := strings.Fields(p) if len(fields) < 2 { continue } typeExpr := fields[len(fields)-2] name := fields[len(fields)-1] nullable := strings.HasSuffix(typeExpr, "?") typeExpr = strings.TrimSuffix(typeExpr, "?") repeated := strings.HasPrefix(typeExpr, "List<") || strings.HasSuffix(typeExpr, "[]") shape.Fields = append(shape.Fields, ShapeField{ Name: name, Type: stripGenericsCSharp(typeExpr), Required: !nullable, Repeated: repeated, }) } if len(shape.Fields) == 0 { return nil } return shape } // splitTopLevelArgsCS is the C# twin of the Go arg splitter — commas // inside `<...>` or `(...)` don't terminate an argument. func splitTopLevelArgsCS(s string) []string { var out []string depth := 0 start := 0 for i := 0; i < len(s); i++ { switch s[i] { case '<', '(', '[': depth++ case '>', ')', ']': depth-- case ',': if depth == 0 { out = append(out, s[start:i]) start = i + 1 } } } if last := strings.TrimSpace(s[start:]); last != "" { out = append(out, last) } return out }