golib/tools/jsontypes/decisions.go
AJ ONeal 89b1191fdd
feat(jsontypes): infer types from JSON, generate code in 9 formats
Add tools/jsontypes library and tools/jsontypes/cmd/jsonpaths CLI.

Given a JSON sample (file, URL, or stdin), walks the structure,
detects maps vs structs, infers optional fields from multiple
instances, and produces typed definitions.

Output formats (--format):
- json-paths: flat type path notation (default)
- go: struct definitions with json tags and union support
- typescript: interfaces with optional/nullable fields
- jsdoc: @typedef annotations
- zod: validation schemas with type inference
- python: TypedDict classes
- sql: CREATE TABLE with FK relationships
- json-schema: draft 2020-12
- json-typedef: RFC 8927

Features:
- Interactive prompts for ambiguous structure (map vs struct, same
  vs different types), with --anonymous mode for non-interactive use
- Answer replay: saves prompt answers to .answers files for iterative
  refinement
- URL fetching with local caching and sensitive param stripping
- Curl-like auth: -H, --bearer, --user, --cookie, --cookie-jar
- Discriminated union support with sealed interfaces, unique-field
  probing, and CHANGE ME comments for type/kind discriminators
- Extensive round-trip compilation tests for generated Go code
2026-03-07 14:34:01 -07:00

464 lines
13 KiB
Go

package jsontypes
import (
"fmt"
"sort"
"strings"
)
// decideMapOrStruct determines whether an object is a map or struct.
// In anonymous mode, uses heuristics silently.
// Otherwise, shows a combined prompt: enter a TypeName or 'm' for map.
// In default mode, confident heuristic maps skip the prompt.
// In askTypes mode, the prompt is always shown.
func (a *Analyzer) decideMapOrStruct(path string, obj map[string]any) bool {
isMap, confident := looksLikeMap(obj)
if a.anonymous {
return isMap
}
// Default mode: skip prompt when heuristics are confident
if !a.askTypes && confident {
return isMap
}
return a.promptMapOrStructWithName(path, obj, isMap, confident)
}
// promptMapOrStructWithName shows the object's fields and asks a combined question.
// The user can type 'm' or 'map' for a map, a name starting with a capital letter
// for a struct type, or press Enter to accept the default.
func (a *Analyzer) promptMapOrStructWithName(path string, obj map[string]any, heuristicMap, confident bool) bool {
keys := sortedKeys(obj)
inferred := inferTypeName(path)
if inferred == "" {
a.typeCounter++
inferred = fmt.Sprintf("Struct%d", a.typeCounter)
}
defaultVal := inferred
if confident && heuristicMap {
defaultVal = "m"
}
fmt.Fprintf(a.Prompter.output, "\nAt %s\n", shortPath(path))
fmt.Fprintf(a.Prompter.output, " Object with %d keys:\n", len(keys))
for _, k := range keys {
fmt.Fprintf(a.Prompter.output, " %s: %s\n", k, valueSummary(obj[k]))
}
answer := a.Prompter.askMapOrName("Struct name (or 'm' for map)?", defaultVal)
if answer == "m" {
a.pendingTypeName = ""
return true
}
a.pendingTypeName = answer
return false
}
// decideKeyName infers the map key type from the keys.
func (a *Analyzer) decideKeyName(_ string, obj map[string]any) string {
return inferKeyName(obj)
}
// decideTypeName determines the struct type name, using inference and optionally
// prompting the user.
func (a *Analyzer) decideTypeName(path string, obj map[string]any) string {
// Check if we've already named a type with this exact shape
sig := shapeSignature(obj)
if existing, ok := a.knownTypes[sig]; ok {
a.pendingTypeName = ""
return existing.name
}
newFields := fieldSet(obj)
// Consume pending name from askTypes combined prompt
if a.pendingTypeName != "" {
name := a.pendingTypeName
a.pendingTypeName = ""
return a.resolveTypeName(path, name, newFields, sig)
}
inferred := inferTypeName(path)
if inferred == "" {
a.typeCounter++
inferred = fmt.Sprintf("Struct%d", a.typeCounter)
}
// Default and anonymous modes: auto-resolve without prompting
if !a.askTypes {
return a.autoResolveTypeName(path, inferred, newFields, sig)
}
// askTypes mode: show fields and prompt for name
keys := sortedKeys(obj)
fmt.Fprintf(a.Prompter.output, "\nAt %s\n", shortPath(path))
fmt.Fprintf(a.Prompter.output, " Struct with %d fields:\n", len(keys))
for _, k := range keys {
fmt.Fprintf(a.Prompter.output, " %s: %s\n", k, valueSummary(obj[k]))
}
name := a.promptName(path, inferred, newFields, sig)
return name
}
// autoResolveTypeName registers or resolves a type name without prompting.
// On collision, tries the parent-prefix strategy; if that also collides, prompts
// (unless anonymous, in which case it uses a numbered fallback).
func (a *Analyzer) autoResolveTypeName(path, name string, newFields map[string]string, sig string) string {
existing, taken := a.typesByName[name]
if !taken {
return a.registerType(sig, name, newFields)
}
rel := fieldRelation(existing.fields, newFields)
switch rel {
case relEqual:
a.knownTypes[sig] = existing
return name
case relSubset, relSuperset:
merged := mergeFieldSets(existing.fields, newFields)
existing.fields = merged
a.knownTypes[sig] = existing
return name
default:
// Collision — try parent-prefix strategy
alt := a.suggestAlternativeName(path, name)
if _, altTaken := a.typesByName[alt]; !altTaken {
return a.registerType(sig, alt, newFields)
}
// Parent strategy also taken
if a.anonymous {
a.typeCounter++
return a.registerType(sig, fmt.Sprintf("%s%d", name, a.typeCounter), newFields)
}
// Last resort: prompt
return a.promptName(path, alt, newFields, sig)
}
}
// resolveTypeName handles a name that came from the combined prompt,
// checking for collisions with existing types.
func (a *Analyzer) resolveTypeName(path, name string, newFields map[string]string, sig string) string {
existing, taken := a.typesByName[name]
if !taken {
return a.registerType(sig, name, newFields)
}
rel := fieldRelation(existing.fields, newFields)
switch rel {
case relEqual:
a.knownTypes[sig] = existing
return name
case relSubset, relSuperset:
merged := mergeFieldSets(existing.fields, newFields)
existing.fields = merged
a.knownTypes[sig] = existing
return name
default:
return a.promptName(path, name, newFields, sig)
}
}
// promptName asks for a type name and handles collisions with existing types.
// Pre-resolves the suggested name so the user sees a valid default.
func (a *Analyzer) promptName(path, suggested string, newFields map[string]string, sig string) string {
suggested = a.preResolveCollision(path, suggested, newFields, sig)
for {
name := a.Prompter.askFreeform("Name for this type?", suggested)
existing, taken := a.typesByName[name]
if !taken {
return a.registerType(sig, name, newFields)
}
rel := fieldRelation(existing.fields, newFields)
switch rel {
case relEqual:
a.knownTypes[sig] = existing
return name
case relSubset, relSuperset:
fmt.Fprintf(a.Prompter.output, " Extending existing type %q (merging fields)\n", name)
merged := mergeFieldSets(existing.fields, newFields)
existing.fields = merged
a.knownTypes[sig] = existing
return name
case relOverlap:
fmt.Fprintf(a.Prompter.output, " Type %q already exists with overlapping fields: %s\n",
name, fieldList(existing.fields))
choice := a.Prompter.ask(
fmt.Sprintf(" [e]xtend %q with merged fields, or use a [d]ifferent name?", name),
"e", []string{"e", "d"},
)
if choice == "e" {
merged := mergeFieldSets(existing.fields, newFields)
existing.fields = merged
a.knownTypes[sig] = existing
return name
}
suggested = a.suggestAlternativeName(path, name)
continue
case relDisjoint:
fmt.Fprintf(a.Prompter.output, " Type %q already exists with different fields: %s\n",
name, fieldList(existing.fields))
suggested = a.suggestAlternativeName(path, name)
continue
}
}
}
// preResolveCollision checks if the suggested name collides with an existing
// type that can't be auto-merged. If so, prints a warning and returns a new
// suggested name.
func (a *Analyzer) preResolveCollision(path, suggested string, newFields map[string]string, sig string) string {
existing, taken := a.typesByName[suggested]
if !taken {
return suggested
}
rel := fieldRelation(existing.fields, newFields)
switch rel {
case relEqual, relSubset, relSuperset:
return suggested
default:
alt := a.suggestAlternativeName(path, suggested)
fmt.Fprintf(a.Prompter.output, " (type %q already exists with different fields, suggesting %q)\n",
suggested, alt)
return alt
}
}
// suggestAlternativeName generates a better name when a collision occurs,
// using the parent type as a prefix (e.g., "DocumentRoom" instead of "Room2").
func (a *Analyzer) suggestAlternativeName(path, collided string) string {
parent := parentTypeName(path)
if parent != "" {
candidate := parent + collided
if _, taken := a.typesByName[candidate]; !taken {
return candidate
}
}
// Fall back to numbered suffix
a.typeCounter++
return fmt.Sprintf("%s%d", collided, a.typeCounter)
}
// shortPath returns the full path but with only the most recent {Type}
// annotation kept; all earlier type annotations are stripped. e.g.:
// ".{RoomsResult}.rooms[]{Room}.room[string][]{RoomRoom}.json{RoomRoomJSON}.feature_types[]"
// → ".rooms[].room[string][].json{RoomRoomJSON}.feature_types[]"
func shortPath(path string) string {
// Find the last {Type} annotation
lastOpen := -1
lastClose := -1
for i := len(path) - 1; i >= 0; i-- {
if path[i] == '}' && lastClose < 0 {
lastClose = i
}
if path[i] == '{' && lastClose >= 0 && lastOpen < 0 {
lastOpen = i
break
}
}
if lastOpen < 0 {
return path
}
// Rebuild: strip all {Type} annotations except the last one
var buf strings.Builder
i := 0
for i < len(path) {
if path[i] == '{' {
end := strings.IndexByte(path[i:], '}')
if end < 0 {
break
}
if i == lastOpen {
// Keep this annotation
buf.WriteString(path[i : i+end+1])
}
i = i + end + 1
} else {
buf.WriteByte(path[i])
i++
}
}
// Collapse any double dots left by stripping (e.g., ".." → ".")
return strings.ReplaceAll(buf.String(), "..", ".")
}
// parentTypeName extracts the most recent {TypeName} from a path.
// e.g., ".[id]{Document}.rooms[int]{Room}.details" → "Room"
func parentTypeName(path string) string {
last := ""
for {
idx := strings.Index(path, "{")
if idx < 0 {
break
}
end := strings.Index(path[idx:], "}")
if end < 0 {
break
}
candidate := path[idx+1 : idx+end]
if candidate != "null" {
last = candidate
}
path = path[idx+end+1:]
}
return last
}
func (a *Analyzer) registerType(sig, name string, fields map[string]string) string {
st := &structType{name: name, fields: fields}
a.knownTypes[sig] = st
a.typesByName[name] = st
return name
}
type fieldRelationType int
const (
relEqual fieldRelationType = iota
relSubset // existing ⊂ new
relSuperset // existing ⊃ new
relOverlap // some shared, some unique to each
relDisjoint // no fields in common
)
func fieldRelation(a, b map[string]string) fieldRelationType {
aInB, bInA := 0, 0
for k, ak := range a {
if bk, ok := b[k]; ok && kindsCompatible(ak, bk) {
aInB++
}
}
for k, bk := range b {
if ak, ok := a[k]; ok && kindsCompatible(ak, bk) {
bInA++
}
}
shared := aInB // same as bInA
if shared == 0 {
return relDisjoint
}
if shared == len(a) && shared == len(b) {
return relEqual
}
if shared == len(a) {
return relSubset // all of a is in b, b has more
}
if shared == len(b) {
return relSuperset // all of b is in a, a has more
}
return relOverlap
}
// kindsCompatible returns true if two field value kinds can be considered the
// same type. "null" is compatible with anything (it's just an absent value),
// and "mixed" is compatible with anything.
func kindsCompatible(a, b string) bool {
if a == b {
return true
}
if a == "null" || b == "null" || a == "mixed" || b == "mixed" {
return true
}
return false
}
// fieldsOverlap returns true if one field set is a subset or superset of the other.
func fieldsOverlap(a, b map[string]string) bool {
rel := fieldRelation(a, b)
return rel == relEqual || rel == relSubset || rel == relSuperset
}
func mergeFieldSets(a, b map[string]string) map[string]string {
merged := make(map[string]string, len(a)+len(b))
for k, v := range a {
merged[k] = v
}
for k, v := range b {
if existing, ok := merged[k]; ok && existing != v {
merged[k] = "mixed"
} else {
merged[k] = v
}
}
return merged
}
func fieldList(fields map[string]string) string {
keys := make([]string, 0, len(fields))
for k := range fields {
keys = append(keys, k)
}
sort.Strings(keys)
return strings.Join(keys, ", ")
}
// decideTupleOrList asks the user if a short mixed-type array is a tuple or list.
func (a *Analyzer) decideTupleOrList(path string, arr []any) bool {
if a.anonymous {
return false // default to list
}
fmt.Fprintf(a.Prompter.output, "\nAt %s\n", shortPath(path))
fmt.Fprintf(a.Prompter.output, " Short array with %d elements of mixed types:\n", len(arr))
for i, v := range arr {
fmt.Fprintf(a.Prompter.output, " [%d]: %s\n", i, valueSummary(v))
}
choice := a.Prompter.ask(
"Is this a [l]ist or a [t]uple?",
"l", []string{"l", "t"},
)
return choice == "t"
}
// valueSummary returns a short human-readable summary of a JSON value.
func valueSummary(v any) string {
switch tv := v.(type) {
case nil:
return "null"
case bool:
return fmt.Sprintf("%v", tv)
case string:
if len(tv) > 40 {
return fmt.Sprintf("%q...", tv[:37])
}
return fmt.Sprintf("%q", tv)
case []any:
if len(tv) == 0 {
return "[]"
}
return fmt.Sprintf("[...] (%d elements)", len(tv))
case map[string]any:
if len(tv) == 0 {
return "{}"
}
keys := sortedKeys(tv)
preview := keys
if len(preview) > 3 {
preview = preview[:3]
}
s := "{" + strings.Join(preview, ", ")
if len(keys) > 3 {
s += ", ..."
}
return s + "}"
default:
return fmt.Sprintf("%v", v)
}
}
func fieldSet(obj map[string]any) map[string]string {
fs := make(map[string]string, len(obj))
for k, v := range obj {
fs[k] = kindOf(v)
}
return fs
}