mirror of
https://github.com/therootcompany/golib.git
synced 2026-11-04 06:55:00 +00:00
snakeToPascal now splits on both underscores and hyphens, producing valid Go identifiers like GenerationI, RubySapphire, BlackWhite instead of invalid ones like Generation-i, Ruby-sapphire, Black-white.
405 lines
10 KiB
Go
405 lines
10 KiB
Go
package jsontypes
|
|
|
|
import (
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
"unicode"
|
|
)
|
|
|
|
// looksLikeMap uses heuristics to decide whether an object is a map (keyed
|
|
// collection) or a struct. Returns (isMap, confident).
|
|
//
|
|
// The logic is deliberately asymmetric: a map misidentified as a struct
|
|
// explodes into hundreds of fields, while a struct misidentified as a map
|
|
// is compact and easy to fix. So when in doubt, default to map.
|
|
//
|
|
// Three rules:
|
|
// 1. All numeric keys → map (certain).
|
|
// 2. Any key is a common word → struct (certain).
|
|
// 3. Everything else → map (safe default).
|
|
func looksLikeMap(obj map[string]any) (isMap bool, confident bool) {
|
|
keys := sortedKeys(obj)
|
|
n := len(keys)
|
|
if n == 0 {
|
|
return false, false
|
|
}
|
|
|
|
// Rule 1: All keys are integers → always a map.
|
|
allInts := true
|
|
for _, k := range keys {
|
|
if _, err := strconv.ParseInt(k, 10, 64); err != nil {
|
|
allInts = false
|
|
break
|
|
}
|
|
}
|
|
if allInts {
|
|
return true, true
|
|
}
|
|
|
|
// Rule 2: Keys that look like words → struct.
|
|
// With few keys, one word is enough. With many keys, a majority must
|
|
// be words — otherwise a few coincidental matches (e.g., "beef" in hex)
|
|
// could misclassify a map.
|
|
wordCount := countWordLikeKeys(keys)
|
|
if n <= 3 {
|
|
if wordCount >= 1 {
|
|
return false, true
|
|
}
|
|
return true, false
|
|
}
|
|
if wordCount > n/2 {
|
|
return false, true
|
|
}
|
|
|
|
// Rule 3: No word majority → default to map.
|
|
return true, true
|
|
}
|
|
|
|
// countWordLikeKeys returns how many keys look like they're composed of words
|
|
// (i.e., struct field names rather than IDs/tokens).
|
|
func countWordLikeKeys(keys []string) int {
|
|
count := 0
|
|
for _, k := range keys {
|
|
if isWordLikeKey(k) {
|
|
count++
|
|
}
|
|
}
|
|
return count
|
|
}
|
|
|
|
// isWordLikeKey checks whether a key is composed of word-like segments,
|
|
// with at least one strong word (3+ chars, pronounceable). Short words
|
|
// like "at", "on", "id" only count when paired with a strong word
|
|
// (e.g., "created_at" has "created" as the strong word).
|
|
//
|
|
// "created_at" → ["created", "at"] → "created" is strong → true
|
|
// "theme" → ["theme"] → strong word → true
|
|
// "at" → ["at"] → no strong word → false
|
|
// "usr_2NBv8C" → ["usr", "2NBv8C"] → "2NBv8C" not a word → false
|
|
func isWordLikeKey(k string) bool {
|
|
segments := splitWordSegments(k)
|
|
if len(segments) == 0 {
|
|
return false
|
|
}
|
|
hasStrongWord := false
|
|
for _, seg := range segments {
|
|
// Pure digits and short segments (< 3 chars) that aren't known
|
|
// short words are neutral — skip without failing.
|
|
// This handles abbreviations like "ms", "db", "ui" in keys
|
|
// like "account_ms" or "build_db".
|
|
if isAllDigits(seg) || (len(seg) < 3 && !commonShortWords[strings.ToLower(seg)]) {
|
|
continue
|
|
}
|
|
if !isWordSegment(seg) {
|
|
return false
|
|
}
|
|
if len(seg) >= 3 && segmentIsPronounceable(seg) {
|
|
hasStrongWord = true
|
|
}
|
|
}
|
|
return hasStrongWord
|
|
}
|
|
|
|
// commonShortWords are words too short for pronounceability checks but
|
|
// commonly found in struct field names.
|
|
var commonShortWords = map[string]bool{
|
|
"id": true, "at": true, "on": true, "by": true, "in": true,
|
|
"to": true, "of": true, "or": true, "is": true, "no": true,
|
|
"do": true, "up": true, "if": true, "go": true, "ok": true,
|
|
}
|
|
|
|
// isWordSegment returns true if a segment looks like a word.
|
|
func isWordSegment(s string) bool {
|
|
if len(s) == 0 {
|
|
return false
|
|
}
|
|
// Pure digits are not words (but are ok as parts of a key like "form1065").
|
|
if isAllDigits(s) {
|
|
return false
|
|
}
|
|
// Short common words.
|
|
if commonShortWords[strings.ToLower(s)] {
|
|
return true
|
|
}
|
|
// 3+ character pronounceable segments are words.
|
|
return len(s) >= 3 && segmentIsPronounceable(s)
|
|
}
|
|
|
|
func segmentIsPronounceable(s string) bool {
|
|
// Strip trailing digits — "form1065" → "form", "line2" → "line".
|
|
core := strings.TrimRightFunc(s, unicode.IsDigit)
|
|
if core == "" {
|
|
return false // all digits
|
|
}
|
|
// If the core still has digits, it's not a word (e.g., "a1b2c3d4" → "a1b2c3d").
|
|
for _, r := range core {
|
|
if unicode.IsDigit(r) {
|
|
return false
|
|
}
|
|
}
|
|
|
|
s = strings.ToLower(core)
|
|
vowels := 0
|
|
consonantRun := 0
|
|
maxConsonantRun := 0
|
|
letters := 0
|
|
|
|
for _, r := range s {
|
|
if !unicode.IsLetter(r) {
|
|
consonantRun = 0
|
|
continue
|
|
}
|
|
letters++
|
|
if isVowel(r) {
|
|
vowels++
|
|
consonantRun = 0
|
|
} else {
|
|
consonantRun++
|
|
if consonantRun > maxConsonantRun {
|
|
maxConsonantRun = consonantRun
|
|
}
|
|
}
|
|
}
|
|
|
|
if letters < 2 {
|
|
return false
|
|
}
|
|
|
|
ratio := float64(vowels) / float64(letters)
|
|
// English words typically have 30-50% vowels.
|
|
// Allow a wide band (15-80%) to cover abbreviations and acronyms.
|
|
if ratio < 0.15 || ratio > 0.80 {
|
|
return false
|
|
}
|
|
// No natural word has 5+ consonants in a row.
|
|
if maxConsonantRun > 4 {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func isVowel(r rune) bool {
|
|
switch r {
|
|
case 'a', 'e', 'i', 'o', 'u', 'y':
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func isAllDigits(s string) bool {
|
|
for _, r := range s {
|
|
if !unicode.IsDigit(r) {
|
|
return false
|
|
}
|
|
}
|
|
return len(s) > 0
|
|
}
|
|
|
|
// splitWordSegments breaks a string into word-like segments by splitting on
|
|
// underscores, hyphens, and camelCase boundaries.
|
|
func splitWordSegments(s string) []string {
|
|
// First split on underscores and hyphens.
|
|
var parts []string
|
|
for _, part := range strings.FieldsFunc(s, func(r rune) bool {
|
|
return r == '_' || r == '-'
|
|
}) {
|
|
// Then split on camelCase boundaries.
|
|
parts = append(parts, splitCamelCase(part)...)
|
|
}
|
|
return parts
|
|
}
|
|
|
|
func splitCamelCase(s string) []string {
|
|
var segments []string
|
|
start := 0
|
|
runes := []rune(s)
|
|
for i := 1; i < len(runes); i++ {
|
|
if unicode.IsUpper(runes[i]) && (i > start) {
|
|
segments = append(segments, string(runes[start:i]))
|
|
start = i
|
|
}
|
|
}
|
|
segments = append(segments, string(runes[start:]))
|
|
return segments
|
|
}
|
|
|
|
// inferKeyName tries to infer a meaningful key name from the map's keys.
|
|
func inferKeyName(obj map[string]any) string {
|
|
keys := sortedKeys(obj)
|
|
if len(keys) == 0 {
|
|
return "string"
|
|
}
|
|
|
|
// All numeric?
|
|
allNum := true
|
|
for _, k := range keys {
|
|
if _, err := strconv.ParseInt(k, 10, 64); err != nil {
|
|
allNum = false
|
|
break
|
|
}
|
|
}
|
|
if allNum {
|
|
return "int"
|
|
}
|
|
|
|
// Check if all values are objects with a common field that matches the
|
|
// key (e.g., keys are "abc123" and objects have an "id" field with "abc123").
|
|
// This suggests the key name is "id".
|
|
for _, fieldName := range []string{"id", "ID", "Id", "_id"} {
|
|
match := true
|
|
for k, v := range obj {
|
|
if m, ok := v.(map[string]any); ok {
|
|
if val, exists := m[fieldName]; exists {
|
|
if fmt.Sprintf("%v", val) == k {
|
|
continue
|
|
}
|
|
}
|
|
}
|
|
match = false
|
|
break
|
|
}
|
|
if match && len(obj) > 0 {
|
|
return fieldName
|
|
}
|
|
}
|
|
|
|
return "string"
|
|
}
|
|
|
|
// ambiguousTypeNames maps lowercase inferred names to their canonical form.
|
|
// When one of these is inferred, the parent type name is prepended and the
|
|
// canonical form is used (e.g., "json" in any casing → ParentJSON).
|
|
var ambiguousTypeNames = map[string]string{
|
|
"json": "JSON",
|
|
"data": "Data",
|
|
"item": "Item",
|
|
"value": "Value",
|
|
"result": "Result",
|
|
}
|
|
|
|
// inferTypeName tries to guess a struct name from the path context.
|
|
func inferTypeName(path string) string {
|
|
// Root path → "Root"
|
|
if path == "." {
|
|
return "Root"
|
|
}
|
|
|
|
// Root-level collection items (no parent type yet)
|
|
// e.g., ".[]", ".[string]", ".[int]"
|
|
if !strings.Contains(path, "{") {
|
|
name := inferTypeNameFromSegments(path)
|
|
if name == "" {
|
|
return "RootItem"
|
|
}
|
|
return name
|
|
}
|
|
|
|
return inferTypeNameFromSegments(path)
|
|
}
|
|
|
|
func inferTypeNameFromSegments(path string) string {
|
|
// Extract the last meaningful segment from the path
|
|
// e.g., ".friends[int]" → "Friend", ".{Person}.address" → "Address"
|
|
parts := strings.FieldsFunc(path, func(r rune) bool {
|
|
return r == '.' || r == '[' || r == ']' || r == '{' || r == '}'
|
|
})
|
|
if len(parts) == 0 {
|
|
return ""
|
|
}
|
|
last := parts[len(parts)-1]
|
|
// Skip index-like segments
|
|
if last == "int" || last == "string" || last == "id" {
|
|
if len(parts) >= 2 {
|
|
last = parts[len(parts)-2]
|
|
} else {
|
|
return ""
|
|
}
|
|
}
|
|
// Strip common suffixes like _id, _key, Id
|
|
last = strings.TrimSuffix(last, "_id")
|
|
last = strings.TrimSuffix(last, "_key")
|
|
last = strings.TrimSuffix(last, "Id")
|
|
last = strings.TrimSuffix(last, "Key")
|
|
if last == "" {
|
|
return ""
|
|
}
|
|
name := singularize(snakeToPascal(last))
|
|
|
|
// If the inferred name is too generic, use canonical form and prepend parent
|
|
if canonical, ok := ambiguousTypeNames[strings.ToLower(name)]; ok {
|
|
parent := parentTypeName(path)
|
|
if parent != "" {
|
|
return parent + canonical
|
|
}
|
|
return canonical
|
|
}
|
|
|
|
return name
|
|
}
|
|
|
|
// isUbiquitousField returns true if a field name is so common across all
|
|
// domains (databases, APIs, languages) that sharing it doesn't imply the
|
|
// objects are the same type. These are excluded when deciding whether to
|
|
// default to "same" or "different" types.
|
|
func isUbiquitousField(name string) bool {
|
|
// Exact matches
|
|
switch name {
|
|
case "id", "ID", "Id", "_id",
|
|
"name", "Name",
|
|
"type", "Type", "_type",
|
|
"kind", "Kind",
|
|
"slug", "Slug",
|
|
"label", "Label",
|
|
"title", "Title",
|
|
"description", "Description":
|
|
return true
|
|
}
|
|
// Suffix patterns: *_at, *_on, *At, *On (timestamps/dates)
|
|
if strings.HasSuffix(name, "_at") || strings.HasSuffix(name, "_on") ||
|
|
strings.HasSuffix(name, "At") || strings.HasSuffix(name, "On") {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// snakeToPascal converts snake_case or camelCase to PascalCase.
|
|
func snakeToPascal(s string) string {
|
|
parts := strings.FieldsFunc(s, func(r rune) bool {
|
|
return r == '_' || r == '-'
|
|
})
|
|
for i, p := range parts {
|
|
parts[i] = capitalize(p)
|
|
}
|
|
return strings.Join(parts, "")
|
|
}
|
|
|
|
func capitalize(s string) string {
|
|
if len(s) == 0 {
|
|
return s
|
|
}
|
|
return strings.ToUpper(s[:1]) + s[1:]
|
|
}
|
|
|
|
// singularize does a naive singularization for common English plurals.
|
|
func singularize(s string) string {
|
|
// Uncountable / already-singular words ending in -s.
|
|
switch strings.ToLower(s) {
|
|
case "species", "series", "chassis", "status", "alias":
|
|
return s
|
|
}
|
|
if strings.HasSuffix(s, "ies") && len(s) > 4 {
|
|
return s[:len(s)-3] + "y"
|
|
}
|
|
if strings.HasSuffix(s, "ses") || strings.HasSuffix(s, "xes") || strings.HasSuffix(s, "zes") {
|
|
return s[:len(s)-2]
|
|
}
|
|
if strings.HasSuffix(s, "ss") || strings.HasSuffix(s, "us") || strings.HasSuffix(s, "is") {
|
|
return s // not plural
|
|
}
|
|
if strings.HasSuffix(s, "s") && len(s) > 3 {
|
|
return s[:len(s)-1]
|
|
}
|
|
return s
|
|
}
|