mirror of
https://github.com/therootcompany/golib.git
synced 2026-03-13 12:27:59 +00:00
ref(jsontypes): replace Prompter with Resolver callback pattern
Separate library from CLI concerns: - Add Resolver callback type with Decision/Response structs for all interactive decisions (map/struct, type name, tuple/list, shape unification, shape naming, name collision) - Move terminal I/O (Prompter) from library to cmd/jsonpaths - Add public API: New(), ParseFormat(), Generate(), AutoGenerate() - Add Format type with aliases (ts, py, json-paths, etc.) - Fix godoc comments to match exported function names - Update tests to use scriptedResolver instead of Prompter internals - Update doc.go and README with current API
This commit is contained in:
parent
40106d14cd
commit
cdadf91459
@ -7,17 +7,34 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// AnalyzerConfig configures an Analyzer.
|
||||||
|
type AnalyzerConfig struct {
|
||||||
|
// Resolver handles interactive decisions during analysis.
|
||||||
|
// If nil, heuristic defaults are used (fully autonomous).
|
||||||
|
//
|
||||||
|
// When set, the resolver is called only when the analyzer is
|
||||||
|
// genuinely unsure: ambiguous map/struct, multiple object shapes,
|
||||||
|
// tuple candidates, and unresolvable name collisions. Confident
|
||||||
|
// heuristic decisions are made without calling the resolver.
|
||||||
|
Resolver Resolver
|
||||||
|
|
||||||
|
// AskTypes prompts for every type name, even when heuristics
|
||||||
|
// are confident. Only meaningful when Resolver is set.
|
||||||
|
AskTypes bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Analyzer holds state for a single JSON analysis pass. Create a new
|
||||||
|
// Analyzer for each JSON document; do not reuse across documents.
|
||||||
type Analyzer struct {
|
type Analyzer struct {
|
||||||
Prompter *Prompter
|
resolver Resolver
|
||||||
anonymous bool
|
autonomous bool
|
||||||
askTypes bool
|
askTypes bool
|
||||||
|
|
||||||
typeCounter int
|
typeCounter int
|
||||||
// knownTypes maps shape signature → type name
|
knownTypes map[string]*structType
|
||||||
knownTypes map[string]*structType
|
|
||||||
// typesByName maps type name → structType for collision detection
|
|
||||||
typesByName map[string]*structType
|
typesByName map[string]*structType
|
||||||
// pendingTypeName is set by the combined map/struct+name prompt
|
// pendingTypeName is set by decideMapOrStruct and consumed by
|
||||||
// and consumed by decideTypeName to avoid double-prompting
|
// decideTypeName to avoid double-prompting.
|
||||||
pendingTypeName string
|
pendingTypeName string
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -27,30 +44,27 @@ type structType struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type shapeGroup struct {
|
type shapeGroup struct {
|
||||||
sig string
|
|
||||||
fields []string
|
fields []string
|
||||||
members []map[string]any
|
members []map[string]any
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAnalyzer(inputIsStdin, anonymous, askTypes bool) (*Analyzer, error) {
|
// New creates an Analyzer with the given configuration.
|
||||||
p, err := NewPrompter(inputIsStdin, anonymous)
|
func New(cfg AnalyzerConfig) *Analyzer {
|
||||||
if err != nil {
|
r := cfg.Resolver
|
||||||
return nil, err
|
autonomous := r == nil
|
||||||
|
if r == nil {
|
||||||
|
r = defaultResolver
|
||||||
}
|
}
|
||||||
return &Analyzer{
|
return &Analyzer{
|
||||||
Prompter: p,
|
resolver: r,
|
||||||
anonymous: anonymous,
|
autonomous: autonomous,
|
||||||
askTypes: askTypes,
|
askTypes: cfg.AskTypes,
|
||||||
knownTypes: make(map[string]*structType),
|
knownTypes: make(map[string]*structType),
|
||||||
typesByName: make(map[string]*structType),
|
typesByName: make(map[string]*structType),
|
||||||
}, nil
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Analyzer) Close() {
|
// Analyze traverses a JSON value depth-first and returns annotated flat paths.
|
||||||
a.Prompter.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
// analyze traverses a JSON value depth-first and returns annotated flat paths.
|
|
||||||
func (a *Analyzer) Analyze(path string, val any) []string {
|
func (a *Analyzer) Analyze(path string, val any) []string {
|
||||||
switch v := val.(type) {
|
switch v := val.(type) {
|
||||||
case nil:
|
case nil:
|
||||||
@ -86,7 +100,7 @@ func (a *Analyzer) analyzeObject(path string, obj map[string]any) []string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *Analyzer) analyzeAsMap(path string, obj map[string]any) []string {
|
func (a *Analyzer) analyzeAsMap(path string, obj map[string]any) []string {
|
||||||
keyName := a.decideKeyName(path, obj)
|
keyName := inferKeyName(obj)
|
||||||
|
|
||||||
// Collect all values and group by shape for type unification
|
// Collect all values and group by shape for type unification
|
||||||
values := make([]any, 0, len(obj))
|
values := make([]any, 0, len(obj))
|
||||||
@ -146,7 +160,7 @@ func (a *Analyzer) analyzeArray(path string, arr []any) []string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check for tuple (short array of mixed types)
|
// Check for tuple (short array of mixed types)
|
||||||
if a.isTupleCandidate(arr) {
|
if isTupleCandidate(arr) {
|
||||||
isTuple := a.decideTupleOrList(path, arr)
|
isTuple := a.decideTupleOrList(path, arr)
|
||||||
if isTuple {
|
if isTuple {
|
||||||
return a.analyzeAsTuple(path, arr)
|
return a.analyzeAsTuple(path, arr)
|
||||||
@ -245,7 +259,6 @@ func (a *Analyzer) unifyObjects(path string, objects []map[string]any) []string
|
|||||||
g.members = append(g.members, obj)
|
g.members = append(g.members, obj)
|
||||||
} else {
|
} else {
|
||||||
g := &shapeGroup{
|
g := &shapeGroup{
|
||||||
sig: sig,
|
|
||||||
fields: sortedKeys(obj),
|
fields: sortedKeys(obj),
|
||||||
members: []map[string]any{obj},
|
members: []map[string]any{obj},
|
||||||
}
|
}
|
||||||
@ -259,8 +272,8 @@ func (a *Analyzer) unifyObjects(path string, objects []map[string]any) []string
|
|||||||
return a.analyzeAsStructMulti(path, objects)
|
return a.analyzeAsStructMulti(path, objects)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Multiple shapes — in anonymous mode default to same type
|
// Multiple shapes — in autonomous mode default to same type
|
||||||
if a.anonymous {
|
if a.autonomous {
|
||||||
return a.analyzeAsStructMulti(path, objects)
|
return a.analyzeAsStructMulti(path, objects)
|
||||||
}
|
}
|
||||||
return a.promptTypeUnification(path, groups, groupOrder)
|
return a.promptTypeUnification(path, groups, groupOrder)
|
||||||
@ -302,66 +315,26 @@ func (a *Analyzer) tryAnalyzeAsMaps(path string, objects []map[string]any) []str
|
|||||||
return a.analyzeAsMap(path, combined)
|
return a.analyzeAsMap(path, combined)
|
||||||
}
|
}
|
||||||
|
|
||||||
// promptTypeUnification presents shape groups to the user and asks if they
|
// promptTypeUnification presents shape groups and asks if they are the same
|
||||||
// are the same type (with optional fields) or different types.
|
// type (with optional fields) or different types.
|
||||||
func (a *Analyzer) promptTypeUnification(path string, groups map[string]*shapeGroup, groupOrder []string) []string {
|
func (a *Analyzer) promptTypeUnification(path string, groups map[string]*shapeGroup, groupOrder []string) []string {
|
||||||
const maxFields = 8
|
|
||||||
|
|
||||||
// Compute shared and unique fields across all shapes
|
// Compute shared and unique fields across all shapes
|
||||||
shared, uniquePerShape := shapeFieldBreakdown(groups, groupOrder)
|
shared, uniquePerShape := shapeFieldBreakdown(groups, groupOrder)
|
||||||
totalInstances := 0
|
|
||||||
for _, sig := range groupOrder {
|
|
||||||
totalInstances += len(groups[sig].members)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Fprintf(a.Prompter.output, "\nAt %s — %d shapes (%d instances):\n",
|
// Build shape summaries for the resolver
|
||||||
shortPath(path), len(groupOrder), totalInstances)
|
shapes := make([]ShapeSummary, len(groupOrder))
|
||||||
|
for i, sig := range groupOrder {
|
||||||
// Show shared fields
|
|
||||||
if len(shared) > 0 {
|
|
||||||
preview := shared
|
|
||||||
if len(preview) > maxFields {
|
|
||||||
preview = preview[:maxFields]
|
|
||||||
}
|
|
||||||
fmt.Fprintf(a.Prompter.output, " shared fields (%d): %s", len(shared), strings.Join(preview, ", "))
|
|
||||||
if len(shared) > maxFields {
|
|
||||||
fmt.Fprintf(a.Prompter.output, ", ...")
|
|
||||||
}
|
|
||||||
fmt.Fprintln(a.Prompter.output)
|
|
||||||
} else {
|
|
||||||
fmt.Fprintf(a.Prompter.output, " no shared fields\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show unique fields per shape (truncated)
|
|
||||||
shownShapes := groupOrder
|
|
||||||
if len(shownShapes) > 5 {
|
|
||||||
shownShapes = shownShapes[:5]
|
|
||||||
}
|
|
||||||
for i, sig := range shownShapes {
|
|
||||||
g := groups[sig]
|
g := groups[sig]
|
||||||
unique := uniquePerShape[sig]
|
shapes[i] = ShapeSummary{
|
||||||
if len(unique) == 0 {
|
Index: i,
|
||||||
fmt.Fprintf(a.Prompter.output, " shape %d (%d instances): no unique fields\n", i+1, len(g.members))
|
Instances: len(g.members),
|
||||||
continue
|
Fields: g.fields,
|
||||||
|
UniqueFields: uniquePerShape[sig],
|
||||||
}
|
}
|
||||||
preview := unique
|
|
||||||
if len(preview) > maxFields {
|
|
||||||
preview = preview[:maxFields]
|
|
||||||
}
|
|
||||||
fmt.Fprintf(a.Prompter.output, " shape %d (%d instances): +%d unique: %s",
|
|
||||||
i+1, len(g.members), len(unique), strings.Join(preview, ", "))
|
|
||||||
if len(unique) > maxFields {
|
|
||||||
fmt.Fprintf(a.Prompter.output, ", ...")
|
|
||||||
}
|
|
||||||
fmt.Fprintln(a.Prompter.output)
|
|
||||||
}
|
|
||||||
if len(groupOrder) > 5 {
|
|
||||||
fmt.Fprintf(a.Prompter.output, " ... and %d more shapes\n", len(groupOrder)-5)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decide default: if unique fields heavily outnumber meaningful shared
|
// Decide default: if unique fields heavily outnumber meaningful shared
|
||||||
// fields, default to "different". Ubiquitous fields (id, name, *_at, etc.)
|
// fields, default to "different".
|
||||||
// don't count as meaningful shared fields.
|
|
||||||
meaningfulShared := 0
|
meaningfulShared := 0
|
||||||
for _, f := range shared {
|
for _, f := range shared {
|
||||||
if !isUbiquitousField(f) {
|
if !isUbiquitousField(f) {
|
||||||
@ -372,34 +345,27 @@ func (a *Analyzer) promptTypeUnification(path string, groups map[string]*shapeGr
|
|||||||
for _, sig := range groupOrder {
|
for _, sig := range groupOrder {
|
||||||
totalUnique += len(uniquePerShape[sig])
|
totalUnique += len(uniquePerShape[sig])
|
||||||
}
|
}
|
||||||
defaultChoice := "s"
|
defaultIsNewType := totalUnique >= 2*meaningfulShared
|
||||||
if totalUnique >= 2*meaningfulShared {
|
|
||||||
defaultChoice = "d"
|
d := &Decision{
|
||||||
|
Kind: DecideUnifyShapes,
|
||||||
|
Path: shortPath(path),
|
||||||
|
Default: Response{IsNewType: defaultIsNewType},
|
||||||
|
Shapes: shapes,
|
||||||
|
SharedFields: shared,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Combined prompt: same/different/show full list
|
// Pre-collect all instances for the common "same type" path.
|
||||||
var choice string
|
var all []map[string]any
|
||||||
for {
|
for _, sig := range groupOrder {
|
||||||
choice = a.Prompter.ask(
|
all = append(all, groups[sig].members...)
|
||||||
"[s]ame type? [d]ifferent? show [f]ull list?",
|
|
||||||
defaultChoice, []string{"s", "d", "f"},
|
|
||||||
)
|
|
||||||
if choice != "f" {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
for i, sig := range groupOrder {
|
|
||||||
g := groups[sig]
|
|
||||||
fmt.Fprintf(a.Prompter.output, " Shape %d (%d instances): %s\n",
|
|
||||||
i+1, len(g.members), strings.Join(g.fields, ", "))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if choice == "s" {
|
if err := a.resolver(d); err != nil {
|
||||||
// Same type — analyze with all instances for field unification
|
return a.analyzeAsStructMulti(path, all)
|
||||||
var all []map[string]any
|
}
|
||||||
for _, sig := range groupOrder {
|
|
||||||
all = append(all, groups[sig].members...)
|
if !d.Response.IsNewType {
|
||||||
}
|
|
||||||
return a.analyzeAsStructMulti(path, all)
|
return a.analyzeAsStructMulti(path, all)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -412,20 +378,29 @@ func (a *Analyzer) promptTypeUnification(path string, groups map[string]*shapeGr
|
|||||||
a.typeCounter++
|
a.typeCounter++
|
||||||
inferred = fmt.Sprintf("Struct%d", a.typeCounter)
|
inferred = fmt.Sprintf("Struct%d", a.typeCounter)
|
||||||
}
|
}
|
||||||
// Pre-resolve collision so the suggested name is valid
|
|
||||||
merged := mergeObjects(g.members)
|
merged := mergeObjects(g.members)
|
||||||
newFields := fieldSet(merged)
|
newFields := fieldSet(merged)
|
||||||
shapeSig := shapeSignature(merged)
|
shapeSig := shapeSignature(merged)
|
||||||
inferred = a.preResolveCollision(path, inferred, newFields, shapeSig)
|
inferred = a.preResolveCollision(path, inferred, newFields)
|
||||||
|
|
||||||
fmt.Fprintf(a.Prompter.output, " Shape %d (%d instances): %s\n",
|
sd := &Decision{
|
||||||
i+1, len(g.members), strings.Join(g.fields, ", "))
|
Kind: DecideShapeName,
|
||||||
name := a.Prompter.askTypeName(
|
Path: shortPath(path),
|
||||||
fmt.Sprintf(" Name for shape %d?", i+1), inferred)
|
Default: Response{Name: inferred},
|
||||||
names[i] = name
|
Fields: objectToFieldSummaries(merged),
|
||||||
|
ShapeIndex: i,
|
||||||
|
}
|
||||||
|
if err := a.resolver(sd); err != nil {
|
||||||
|
names[i] = inferred
|
||||||
|
} else {
|
||||||
|
names[i] = sd.Response.Name
|
||||||
|
if names[i] == "" {
|
||||||
|
names[i] = inferred
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Register early so subsequent shapes see this name as taken
|
// Register early so subsequent shapes see this name as taken
|
||||||
a.registerType(shapeSig, name, newFields)
|
a.registerType(shapeSig, names[i], newFields)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Now analyze each group with its pre-assigned name
|
// Now analyze each group with its pre-assigned name
|
||||||
@ -489,7 +464,7 @@ func sortedFieldCount(m map[string]int) []string {
|
|||||||
|
|
||||||
// isTupleCandidate returns true if the array might be a tuple:
|
// isTupleCandidate returns true if the array might be a tuple:
|
||||||
// short (2-5 elements) with mixed types.
|
// short (2-5 elements) with mixed types.
|
||||||
func (a *Analyzer) isTupleCandidate(arr []any) bool {
|
func isTupleCandidate(arr []any) bool {
|
||||||
if len(arr) < 2 || len(arr) > 5 {
|
if len(arr) < 2 || len(arr) > 5 {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,28 +1,39 @@
|
|||||||
package jsontypes
|
package jsontypes
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
// testAnalyzer creates an analyzer in anonymous mode (no prompts).
|
// testAnalyzer creates an analyzer in autonomous mode (no resolver).
|
||||||
func testAnalyzer(t *testing.T) *Analyzer {
|
func testAnalyzer(t *testing.T) *Analyzer {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
a := &Analyzer{
|
return New(AnalyzerConfig{})
|
||||||
Prompter: &Prompter{
|
}
|
||||||
reader: nil,
|
|
||||||
output: os.Stderr,
|
// scriptedResolver creates a Resolver that returns responses in order.
|
||||||
},
|
// After all responses are consumed, it accepts defaults.
|
||||||
anonymous: true,
|
func scriptedResolver(responses ...Response) Resolver {
|
||||||
knownTypes: make(map[string]*structType),
|
i := 0
|
||||||
typesByName: make(map[string]*structType),
|
return func(d *Decision) error {
|
||||||
|
if i >= len(responses) {
|
||||||
|
d.Response = d.Default
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
d.Response = responses[i]
|
||||||
|
i++
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
return a
|
}
|
||||||
|
|
||||||
|
// testInteractiveAnalyzer creates an analyzer with scripted responses.
|
||||||
|
func testInteractiveAnalyzer(t *testing.T, responses ...Response) *Analyzer {
|
||||||
|
t.Helper()
|
||||||
|
return New(AnalyzerConfig{
|
||||||
|
Resolver: scriptedResolver(responses...),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func sortPaths(paths []string) []string {
|
func sortPaths(paths []string) []string {
|
||||||
@ -36,14 +47,13 @@ func TestAnalyzePrimitive(t *testing.T) {
|
|||||||
a := testAnalyzer(t)
|
a := testAnalyzer(t)
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
json string
|
|
||||||
want string
|
want string
|
||||||
}{
|
}{
|
||||||
{"null", "", ".{null}"},
|
{"null", ".{null}"},
|
||||||
{"bool", "", ".{bool}"},
|
{"bool", ".{bool}"},
|
||||||
{"int", "", ".{int}"},
|
{"int", ".{int}"},
|
||||||
{"float", "", ".{float}"},
|
{"float", ".{float}"},
|
||||||
{"string", "", ".{string}"},
|
{"string", ".{string}"},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
@ -427,15 +437,19 @@ func TestAnalyzeFullSample(t *testing.T) {
|
|||||||
assertPaths(t, paths, want)
|
assertPaths(t, paths, want)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestDifferentTypesPromptsForNames verifies that when the user chooses
|
// TestDifferentTypesViaResolver verifies that when the resolver returns
|
||||||
// "different" for multiple shapes at the same path:
|
// IsNewType=true for multiple shapes at the same path:
|
||||||
// 1. They are prompted to name each shape group
|
// 1. Shape names are requested via DecideShapeName
|
||||||
// 2. All names are collected BEFORE recursing into children
|
// 2. The named types appear in the final output
|
||||||
// 3. The named types appear in the final output
|
func TestDifferentTypesViaResolver(t *testing.T) {
|
||||||
func TestDifferentTypesPromptsForNames(t *testing.T) {
|
a := New(AnalyzerConfig{
|
||||||
// Simulate: a Room has items[] containing two distinct shapes, each with
|
Resolver: scriptedResolver(
|
||||||
// a nested "meta" object. Names should be asked for both shapes before
|
Response{IsNewType: true}, // different types for shapes
|
||||||
// the meta objects are analyzed.
|
Response{Name: "FileField"}, // name for shape 1
|
||||||
|
Response{Name: "FeatureField"}, // name for shape 2
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
arr := []any{
|
arr := []any{
|
||||||
// Shape 1: has "filename" and "is_required"
|
// Shape 1: has "filename" and "is_required"
|
||||||
map[string]any{"slug": "a", "filename": "x.pdf", "is_required": true,
|
map[string]any{"slug": "a", "filename": "x.pdf", "is_required": true,
|
||||||
@ -449,19 +463,8 @@ func TestDifferentTypesPromptsForNames(t *testing.T) {
|
|||||||
"meta": map[string]any{"version": jsonNum("2")}},
|
"meta": map[string]any{"version": jsonNum("2")}},
|
||||||
}
|
}
|
||||||
|
|
||||||
var output strings.Builder
|
|
||||||
a := &Analyzer{
|
|
||||||
Prompter: &Prompter{
|
|
||||||
reader: bufio.NewReader(strings.NewReader("")),
|
|
||||||
output: &output,
|
|
||||||
priorAnswers: []string{"d", "FileField", "FeatureField"},
|
|
||||||
},
|
|
||||||
knownTypes: make(map[string]*structType),
|
|
||||||
typesByName: make(map[string]*structType),
|
|
||||||
}
|
|
||||||
paths := sortPaths(a.Analyze(".{Room}.items[]", arr))
|
paths := sortPaths(a.Analyze(".{Room}.items[]", arr))
|
||||||
|
|
||||||
// Verify both named types appear in the paths
|
|
||||||
hasFileField := false
|
hasFileField := false
|
||||||
hasFeatureField := false
|
hasFeatureField := false
|
||||||
for _, p := range paths {
|
for _, p := range paths {
|
||||||
@ -479,17 +482,6 @@ func TestDifferentTypesPromptsForNames(t *testing.T) {
|
|||||||
t.Errorf("expected {FeatureField} type in paths:\n %s", strings.Join(paths, "\n "))
|
t.Errorf("expected {FeatureField} type in paths:\n %s", strings.Join(paths, "\n "))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify that both "Name for shape" prompts appear before any deeper prompts
|
|
||||||
out := output.String()
|
|
||||||
name1Idx := strings.Index(out, "Name for shape 1?")
|
|
||||||
name2Idx := strings.Index(out, "Name for shape 2?")
|
|
||||||
if name1Idx < 0 || name2Idx < 0 {
|
|
||||||
t.Fatalf("expected both shape name prompts in output:\n%s", out)
|
|
||||||
}
|
|
||||||
if name1Idx > name2Idx {
|
|
||||||
t.Errorf("shape 1 name prompt should appear before shape 2")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify the formatted output includes these types
|
// Verify the formatted output includes these types
|
||||||
formatted := FormatPaths(paths)
|
formatted := FormatPaths(paths)
|
||||||
foundFileField := false
|
foundFileField := false
|
||||||
@ -510,109 +502,64 @@ func TestDifferentTypesPromptsForNames(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestCombinedPromptShowsTypeName verifies the default-mode prompt shows
|
// TestDecideMapOrStructDefault verifies that the library sends
|
||||||
// [Root/m] (inferred name + map option), not [s/m] or [S/m].
|
// the inferred type name as the default in DecideMapOrStruct decisions.
|
||||||
func TestCombinedPromptShowsTypeName(t *testing.T) {
|
func TestDecideMapOrStructDefault(t *testing.T) {
|
||||||
var output strings.Builder
|
var captured *Decision
|
||||||
a := &Analyzer{
|
a := New(AnalyzerConfig{
|
||||||
Prompter: &Prompter{
|
Resolver: func(d *Decision) error {
|
||||||
reader: bufio.NewReader(strings.NewReader("")),
|
if d.Kind == DecideMapOrStruct && captured == nil {
|
||||||
output: &output,
|
cp := *d
|
||||||
priorAnswers: []string{"Root"}, // accept default
|
captured = &cp
|
||||||
|
}
|
||||||
|
d.Response = d.Default
|
||||||
|
return nil
|
||||||
},
|
},
|
||||||
knownTypes: make(map[string]*structType),
|
|
||||||
typesByName: make(map[string]*structType),
|
|
||||||
}
|
|
||||||
|
|
||||||
obj := map[string]any{
|
|
||||||
"errors": []any{},
|
|
||||||
"rooms": []any{map[string]any{"name": "foo"}},
|
|
||||||
}
|
|
||||||
a.Analyze(".", obj)
|
|
||||||
|
|
||||||
out := output.String()
|
|
||||||
if !strings.Contains(out, "[Root/m]") {
|
|
||||||
t.Errorf("expected prompt to contain [Root/m], got output:\n%s", out)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestCombinedPromptIgnoresOldPriorAnswer verifies that prior answers like
|
|
||||||
// "s" from old answer files don't corrupt the prompt default.
|
|
||||||
func TestCombinedPromptIgnoresOldPriorAnswer(t *testing.T) {
|
|
||||||
var output strings.Builder
|
|
||||||
a := &Analyzer{
|
|
||||||
Prompter: &Prompter{
|
|
||||||
reader: bufio.NewReader(strings.NewReader("")),
|
|
||||||
output: &output,
|
|
||||||
priorAnswers: []string{"s"}, // old-style answer
|
|
||||||
},
|
|
||||||
knownTypes: make(map[string]*structType),
|
|
||||||
typesByName: make(map[string]*structType),
|
|
||||||
}
|
|
||||||
|
|
||||||
obj := map[string]any{
|
|
||||||
"errors": []any{},
|
|
||||||
"rooms": []any{map[string]any{"name": "foo"}},
|
|
||||||
}
|
|
||||||
a.Analyze(".", obj)
|
|
||||||
|
|
||||||
out := output.String()
|
|
||||||
if strings.Contains(out, "[s/m]") {
|
|
||||||
t.Errorf("old prior answer 's' should not appear in prompt, got output:\n%s", out)
|
|
||||||
}
|
|
||||||
if !strings.Contains(out, "[Root/m]") {
|
|
||||||
t.Errorf("expected prompt to contain [Root/m], got output:\n%s", out)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestOldAnswerFileDoesNotDesync verifies that an old-format answer "s" for
|
|
||||||
// map/struct is consumed (not skipped), so subsequent answers stay in sync.
|
|
||||||
func TestOldAnswerFileDoesNotDesync(t *testing.T) {
|
|
||||||
// Prior answers: "s" (old struct answer for root), then "s" (same type
|
|
||||||
// for a shape unification prompt). The "s" at position 0 should be consumed
|
|
||||||
// by askMapOrName (treated as "accept default"), and "s" at position 1
|
|
||||||
// should be consumed by the ask() for same/different.
|
|
||||||
a := testInteractiveAnalyzer(t, []string{
|
|
||||||
"s", // old-format: accept struct default → Root
|
|
||||||
"s", // same type for shapes
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// An array with two shapes that will trigger unification prompt
|
obj := map[string]any{
|
||||||
arr := []any{
|
"errors": []any{},
|
||||||
map[string]any{"name": "Alice", "x": jsonNum("1")},
|
"rooms": []any{map[string]any{"name": "foo"}},
|
||||||
map[string]any{"name": "Bob", "y": jsonNum("2")},
|
|
||||||
}
|
}
|
||||||
obj := map[string]any{"items": arr}
|
a.Analyze(".", obj)
|
||||||
paths := sortPaths(a.Analyze(".", obj))
|
|
||||||
|
|
||||||
// Should have Root type (from "s" → accept default) and Item type
|
if captured == nil {
|
||||||
// unified as same type (from "s" → same)
|
t.Fatal("expected DecideMapOrStruct decision")
|
||||||
hasRoot := false
|
|
||||||
for _, p := range paths {
|
|
||||||
if strings.Contains(p, "{Root}") {
|
|
||||||
hasRoot = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if !hasRoot {
|
if captured.Default.Name != "Root" {
|
||||||
t.Errorf("expected {Root} type (old 's' should accept default), got:\n %s",
|
t.Errorf("expected default name %q, got %q", "Root", captured.Default.Name)
|
||||||
strings.Join(paths, "\n "))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestDefaultDifferentWhenUniqueFieldsDominate verifies that when shapes share
|
// TestDefaultDifferentWhenUniqueFieldsDominate verifies that when shapes share
|
||||||
// only ubiquitous fields (slug, name, etc.) and have many unique fields, the
|
// only ubiquitous fields (slug, name, etc.) and have many unique fields, the
|
||||||
// prompt defaults to "d" (different) instead of "s" (same).
|
// default response suggests different types (IsNewType=true).
|
||||||
func TestDefaultDifferentWhenUniqueFieldsDominate(t *testing.T) {
|
func TestDefaultDifferentWhenUniqueFieldsDominate(t *testing.T) {
|
||||||
// Two shapes sharing only "slug" (ubiquitous) with 2+ unique fields each.
|
var unifyDecision *Decision
|
||||||
// With no prior answer for same/different, the default should be "d".
|
a := New(AnalyzerConfig{
|
||||||
// Then we need type names for each shape.
|
Resolver: func(d *Decision) error {
|
||||||
// Shape ordering is insertion order: shape 1 = filename,is_required,slug; shape 2 = archived,feature,slug
|
if d.Kind == DecideUnifyShapes && unifyDecision == nil {
|
||||||
a := testInteractiveAnalyzer(t, []string{
|
cp := *d
|
||||||
"Root", // root object has 1 key → not confident, prompts for struct/map
|
unifyDecision = &cp
|
||||||
"d", // accept default (should be "d" because unique >> meaningful shared)
|
}
|
||||||
"FileField", // name for shape 1 (filename, is_required, slug)
|
// For shape unification, accept the default; for other decisions
|
||||||
"FeatureField", // name for shape 2 (archived, feature, slug)
|
// provide the expected responses.
|
||||||
|
switch d.Kind {
|
||||||
|
case DecideMapOrStruct:
|
||||||
|
d.Response = Response{Name: "Root"}
|
||||||
|
case DecideUnifyShapes:
|
||||||
|
d.Response = d.Default
|
||||||
|
case DecideShapeName:
|
||||||
|
if d.ShapeIndex == 0 {
|
||||||
|
d.Response = Response{Name: "FileField"}
|
||||||
|
} else {
|
||||||
|
d.Response = Response{Name: "FeatureField"}
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
d.Response = d.Default
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
arr := []any{
|
arr := []any{
|
||||||
@ -622,6 +569,13 @@ func TestDefaultDifferentWhenUniqueFieldsDominate(t *testing.T) {
|
|||||||
obj := map[string]any{"items": arr}
|
obj := map[string]any{"items": arr}
|
||||||
paths := sortPaths(a.Analyze(".", obj))
|
paths := sortPaths(a.Analyze(".", obj))
|
||||||
|
|
||||||
|
if unifyDecision == nil {
|
||||||
|
t.Fatal("expected DecideUnifyShapes decision")
|
||||||
|
}
|
||||||
|
if !unifyDecision.Default.IsNewType {
|
||||||
|
t.Error("expected default IsNewType=true when unique fields dominate")
|
||||||
|
}
|
||||||
|
|
||||||
// Should have both FileField and FeatureField as separate types
|
// Should have both FileField and FeatureField as separate types
|
||||||
hasFile := false
|
hasFile := false
|
||||||
hasFeature := false
|
hasFeature := false
|
||||||
@ -640,14 +594,23 @@ func TestDefaultDifferentWhenUniqueFieldsDominate(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TestDefaultSameWhenMeaningfulFieldsShared verifies that when shapes share
|
// TestDefaultSameWhenMeaningfulFieldsShared verifies that when shapes share
|
||||||
// many non-ubiquitous fields, the prompt defaults to "s" (same).
|
// many non-ubiquitous fields, the default response suggests same type.
|
||||||
func TestDefaultSameWhenMeaningfulFieldsShared(t *testing.T) {
|
func TestDefaultSameWhenMeaningfulFieldsShared(t *testing.T) {
|
||||||
// Two shapes sharing "email", "phone", "address" (non-ubiquitous) with
|
var unifyDecision *Decision
|
||||||
// only 1 unique field each. unique (2) < 2 * meaningful shared (3), so
|
a := New(AnalyzerConfig{
|
||||||
// default should be "s".
|
Resolver: func(d *Decision) error {
|
||||||
a := testInteractiveAnalyzer(t, []string{
|
if d.Kind == DecideUnifyShapes && unifyDecision == nil {
|
||||||
"Root", // root object has 1 key → not confident, prompts for struct/map
|
cp := *d
|
||||||
"s", // accept default (should be "s")
|
unifyDecision = &cp
|
||||||
|
}
|
||||||
|
switch d.Kind {
|
||||||
|
case DecideMapOrStruct:
|
||||||
|
d.Response = Response{Name: "Root"}
|
||||||
|
default:
|
||||||
|
d.Response = d.Default
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
arr := []any{
|
arr := []any{
|
||||||
@ -657,7 +620,14 @@ func TestDefaultSameWhenMeaningfulFieldsShared(t *testing.T) {
|
|||||||
obj := map[string]any{"people": arr}
|
obj := map[string]any{"people": arr}
|
||||||
paths := sortPaths(a.Analyze(".", obj))
|
paths := sortPaths(a.Analyze(".", obj))
|
||||||
|
|
||||||
// Should be unified as one type (People → singular People) with optional fields
|
if unifyDecision == nil {
|
||||||
|
t.Fatal("expected DecideUnifyShapes decision")
|
||||||
|
}
|
||||||
|
if unifyDecision.Default.IsNewType {
|
||||||
|
t.Error("expected default IsNewType=false when meaningful fields are shared")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should be unified as one type with optional fields
|
||||||
typeCount := 0
|
typeCount := 0
|
||||||
for _, p := range paths {
|
for _, p := range paths {
|
||||||
if strings.Contains(p, "{People}") {
|
if strings.Contains(p, "{People}") {
|
||||||
@ -700,21 +670,6 @@ func TestIsUbiquitousField(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// testInteractiveAnalyzer creates an analyzer with scripted answers (not anonymous).
|
|
||||||
func testInteractiveAnalyzer(t *testing.T, answers []string) *Analyzer {
|
|
||||||
t.Helper()
|
|
||||||
a := &Analyzer{
|
|
||||||
Prompter: &Prompter{
|
|
||||||
reader: bufio.NewReader(strings.NewReader("")),
|
|
||||||
output: io.Discard,
|
|
||||||
priorAnswers: answers,
|
|
||||||
},
|
|
||||||
knownTypes: make(map[string]*structType),
|
|
||||||
typesByName: make(map[string]*structType),
|
|
||||||
}
|
|
||||||
return a
|
|
||||||
}
|
|
||||||
|
|
||||||
// helpers
|
// helpers
|
||||||
|
|
||||||
func jsonNum(s string) json.Number {
|
func jsonNum(s string) json.Number {
|
||||||
|
|||||||
117
tools/jsontypes/auto.go
Normal file
117
tools/jsontypes/auto.go
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
package jsontypes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Format identifies a target output format for type generation.
|
||||||
|
type Format string
|
||||||
|
|
||||||
|
const (
|
||||||
|
FormatGo Format = "go"
|
||||||
|
FormatTypeScript Format = "typescript"
|
||||||
|
FormatJSDoc Format = "jsdoc"
|
||||||
|
FormatZod Format = "zod"
|
||||||
|
FormatPython Format = "python"
|
||||||
|
FormatSQL Format = "sql"
|
||||||
|
FormatJSONSchema Format = "jsonschema"
|
||||||
|
FormatTypedef Format = "typedef"
|
||||||
|
FormatFlatPaths Format = "paths"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Options configures AutoGenerate behavior.
|
||||||
|
type Options struct {
|
||||||
|
// Format selects the output format (default: FormatFlatPaths).
|
||||||
|
Format Format
|
||||||
|
|
||||||
|
// Resolver handles interactive decisions during analysis.
|
||||||
|
// If nil, heuristic defaults are used (fully autonomous).
|
||||||
|
Resolver Resolver
|
||||||
|
|
||||||
|
// AskTypes prompts for every type name, even when heuristics
|
||||||
|
// are confident. Only meaningful when Resolver is set.
|
||||||
|
AskTypes bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseFormat normalizes a format string, accepting common aliases.
|
||||||
|
// Returns an error for unrecognized formats.
|
||||||
|
func ParseFormat(s string) (Format, error) {
|
||||||
|
if f, ok := formatAliases[s]; ok {
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("unknown format: %q (use: paths, go, typescript, jsdoc, zod, python, sql, jsonschema, typedef)", s)
|
||||||
|
}
|
||||||
|
|
||||||
|
var formatAliases = map[string]Format{
|
||||||
|
"": FormatFlatPaths,
|
||||||
|
"paths": FormatFlatPaths,
|
||||||
|
"json-paths": FormatFlatPaths,
|
||||||
|
"go": FormatGo,
|
||||||
|
"typescript": FormatTypeScript,
|
||||||
|
"ts": FormatTypeScript,
|
||||||
|
"jsdoc": FormatJSDoc,
|
||||||
|
"zod": FormatZod,
|
||||||
|
"python": FormatPython,
|
||||||
|
"py": FormatPython,
|
||||||
|
"sql": FormatSQL,
|
||||||
|
"jsonschema": FormatJSONSchema,
|
||||||
|
"json-schema": FormatJSONSchema,
|
||||||
|
"typedef": FormatTypedef,
|
||||||
|
"json-typedef": FormatTypedef,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate renders formatted paths into the given output format.
|
||||||
|
// Use FormatFlatPaths to get the intermediate path notation.
|
||||||
|
func Generate(format Format, paths []string) (string, error) {
|
||||||
|
if format == FormatFlatPaths {
|
||||||
|
return strings.Join(paths, "\n") + "\n", nil
|
||||||
|
}
|
||||||
|
gen, ok := generators[format]
|
||||||
|
if !ok {
|
||||||
|
return "", fmt.Errorf("unknown format: %q", format)
|
||||||
|
}
|
||||||
|
return gen(paths), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AutoGenerate parses JSON from raw bytes and generates type definitions.
|
||||||
|
func AutoGenerate(data []byte, opts Options) (string, error) {
|
||||||
|
var v any
|
||||||
|
dec := json.NewDecoder(bytes.NewReader(data))
|
||||||
|
dec.UseNumber()
|
||||||
|
if err := dec.Decode(&v); err != nil {
|
||||||
|
return "", fmt.Errorf("invalid JSON: %w", err)
|
||||||
|
}
|
||||||
|
return AutoGenerateFromAny(v, opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AutoGenerateFromString parses a JSON string and generates type definitions.
|
||||||
|
func AutoGenerateFromString(s string, opts Options) (string, error) {
|
||||||
|
return AutoGenerate([]byte(s), opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AutoGenerateFromAny generates type definitions from an already-decoded JSON
|
||||||
|
// value. The value must have been decoded with json.UseNumber() so that
|
||||||
|
// integers and floats are distinguishable.
|
||||||
|
func AutoGenerateFromAny(v any, opts Options) (string, error) {
|
||||||
|
a := New(AnalyzerConfig{Resolver: opts.Resolver, AskTypes: opts.AskTypes})
|
||||||
|
paths := FormatPaths(a.Analyze(".", v))
|
||||||
|
format := opts.Format
|
||||||
|
if format == "" {
|
||||||
|
format = FormatFlatPaths
|
||||||
|
}
|
||||||
|
return Generate(format, paths)
|
||||||
|
}
|
||||||
|
|
||||||
|
var generators = map[Format]func([]string) string{
|
||||||
|
FormatGo: GenerateGoStructs,
|
||||||
|
FormatTypeScript: GenerateTypeScript,
|
||||||
|
FormatJSDoc: GenerateJSDoc,
|
||||||
|
FormatZod: GenerateZod,
|
||||||
|
FormatPython: GeneratePython,
|
||||||
|
FormatSQL: GenerateSQL,
|
||||||
|
FormatJSONSchema: GenerateJSONSchema,
|
||||||
|
FormatTypedef: GenerateTypedef,
|
||||||
|
}
|
||||||
@ -178,14 +178,19 @@ The core logic is available as a Go package:
|
|||||||
```go
|
```go
|
||||||
import "github.com/therootcompany/golib/tools/jsontypes"
|
import "github.com/therootcompany/golib/tools/jsontypes"
|
||||||
|
|
||||||
a, _ := jsontypes.NewAnalyzer(false, true, false) // anonymous mode
|
// One-shot: parse JSON and generate code in one call
|
||||||
defer a.Close()
|
out, err := jsontypes.AutoGenerate(jsonBytes, jsontypes.Options{
|
||||||
|
Format: jsontypes.FormatTypeScript,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Or step by step:
|
||||||
|
a := jsontypes.New(jsontypes.AnalyzerConfig{})
|
||||||
|
|
||||||
var data any
|
var data any
|
||||||
// ... json.Decode with UseNumber() ...
|
// ... json.Decode with UseNumber() ...
|
||||||
|
|
||||||
paths := jsontypes.FormatPaths(a.Analyze(".", data))
|
paths := jsontypes.FormatPaths(a.Analyze(".", data))
|
||||||
fmt.Print(jsontypes.GenerateTypeScript(paths))
|
out, err = jsontypes.Generate(jsontypes.FormatTypeScript, paths)
|
||||||
```
|
```
|
||||||
|
|
||||||
See the [package documentation](https://pkg.go.dev/github.com/therootcompany/golib/tools/jsontypes)
|
See the [package documentation](https://pkg.go.dev/github.com/therootcompany/golib/tools/jsontypes)
|
||||||
|
|||||||
@ -2,9 +2,11 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
|
"bytes"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@ -92,6 +94,12 @@ func main() {
|
|||||||
|
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
|
outFormat, err := jsontypes.ParseFormat(*format)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
var input io.Reader
|
var input io.Reader
|
||||||
var baseName string // base filename for .paths and .answers files
|
var baseName string // base filename for .paths and .answers files
|
||||||
inputIsStdin := true
|
inputIsStdin := true
|
||||||
@ -158,46 +166,34 @@ func main() {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
a, err := jsontypes.NewAnalyzer(inputIsStdin, *anonymous, *askTypes)
|
var resolver jsontypes.Resolver
|
||||||
if err != nil {
|
var pr *prompter
|
||||||
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
if !*anonymous {
|
||||||
os.Exit(1)
|
pr, err = newPrompter(inputIsStdin)
|
||||||
}
|
if err != nil {
|
||||||
defer a.Close()
|
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
// Load prior answers if available
|
}
|
||||||
if baseName != "" && !*anonymous {
|
defer pr.close()
|
||||||
a.Prompter.LoadAnswers(baseName + ".answers")
|
if baseName != "" {
|
||||||
|
pr.loadAnswers(baseName + ".answers")
|
||||||
|
}
|
||||||
|
resolver = newCLIResolver(pr)
|
||||||
}
|
}
|
||||||
|
a := jsontypes.New(jsontypes.AnalyzerConfig{
|
||||||
|
Resolver: resolver,
|
||||||
|
AskTypes: *askTypes,
|
||||||
|
})
|
||||||
|
|
||||||
rawPaths := a.Analyze(".", data)
|
rawPaths := a.Analyze(".", data)
|
||||||
formatted := jsontypes.FormatPaths(rawPaths)
|
formatted := jsontypes.FormatPaths(rawPaths)
|
||||||
|
|
||||||
switch *format {
|
out, err := jsontypes.Generate(outFormat, formatted)
|
||||||
case "go":
|
if err != nil {
|
||||||
fmt.Print(jsontypes.GenerateGoStructs(formatted))
|
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||||
case "json-typedef":
|
|
||||||
fmt.Print(jsontypes.GenerateTypedef(formatted))
|
|
||||||
case "json-schema":
|
|
||||||
fmt.Print(jsontypes.GenerateJSONSchema(formatted))
|
|
||||||
case "typescript", "ts":
|
|
||||||
fmt.Print(jsontypes.GenerateTypeScript(formatted))
|
|
||||||
case "jsdoc":
|
|
||||||
fmt.Print(jsontypes.GenerateJSDoc(formatted))
|
|
||||||
case "zod":
|
|
||||||
fmt.Print(jsontypes.GenerateZod(formatted))
|
|
||||||
case "python", "py":
|
|
||||||
fmt.Print(jsontypes.GeneratePython(formatted))
|
|
||||||
case "sql":
|
|
||||||
fmt.Print(jsontypes.GenerateSQL(formatted))
|
|
||||||
case "json-paths", "paths", "":
|
|
||||||
for _, p := range formatted {
|
|
||||||
fmt.Println(p)
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
fmt.Fprintf(os.Stderr, "error: unknown format %q (use: json-paths, go, json-schema, json-typedef, typescript, jsdoc, zod, python, sql)\n", *format)
|
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
fmt.Print(out)
|
||||||
|
|
||||||
// Save outputs
|
// Save outputs
|
||||||
if baseName != "" {
|
if baseName != "" {
|
||||||
@ -206,9 +202,9 @@ func main() {
|
|||||||
fmt.Fprintf(os.Stderr, "warning: could not write %s: %v\n", pathsFile, err)
|
fmt.Fprintf(os.Stderr, "warning: could not write %s: %v\n", pathsFile, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !*anonymous {
|
if !*anonymous && pr != nil {
|
||||||
answersFile := baseName + ".answers"
|
answersFile := baseName + ".answers"
|
||||||
if err := a.Prompter.SaveAnswers(answersFile); err != nil {
|
if err := pr.saveAnswers(answersFile); err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "warning: could not write %s: %v\n", answersFile, err)
|
fmt.Fprintf(os.Stderr, "warning: could not write %s: %v\n", answersFile, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -291,8 +287,9 @@ func isSensitiveParam(name string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func fetchOrCache(rawURL string, timeout time.Duration, noCache bool, extraHeaders http.Header) (io.ReadCloser, error) {
|
func fetchOrCache(rawURL string, timeout time.Duration, noCache bool, extraHeaders http.Header) (io.ReadCloser, error) {
|
||||||
|
path := slugify(rawURL)
|
||||||
|
|
||||||
if !noCache {
|
if !noCache {
|
||||||
path := slugify(rawURL)
|
|
||||||
if info, err := os.Stat(path); err == nil && info.Size() > 0 {
|
if info, err := os.Stat(path); err == nil && info.Size() > 0 {
|
||||||
f, err := os.Open(path)
|
f, err := os.Open(path)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@ -311,7 +308,6 @@ func fetchOrCache(rawURL string, timeout time.Duration, noCache bool, extraHeade
|
|||||||
return body, nil
|
return body, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
path := slugify(rawURL)
|
|
||||||
data, err := io.ReadAll(body)
|
data, err := io.ReadAll(body)
|
||||||
body.Close()
|
body.Close()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -324,7 +320,7 @@ func fetchOrCache(rawURL string, timeout time.Duration, noCache bool, extraHeade
|
|||||||
fmt.Fprintf(os.Stderr, "cached to ./%s\n", path)
|
fmt.Fprintf(os.Stderr, "cached to ./%s\n", path)
|
||||||
}
|
}
|
||||||
|
|
||||||
return io.NopCloser(strings.NewReader(string(data))), nil
|
return io.NopCloser(bytes.NewReader(data)), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchURL(url string, timeout time.Duration, extraHeaders http.Header) (io.ReadCloser, error) {
|
func fetchURL(url string, timeout time.Duration, extraHeaders http.Header) (io.ReadCloser, error) {
|
||||||
@ -391,7 +387,8 @@ func fetchURL(url string, timeout time.Duration, extraHeaders http.Header) (io.R
|
|||||||
}
|
}
|
||||||
|
|
||||||
func isTimeout(err error) bool {
|
func isTimeout(err error) bool {
|
||||||
if netErr, ok := err.(net.Error); ok {
|
var netErr net.Error
|
||||||
|
if errors.As(err, &netErr) {
|
||||||
return netErr.Timeout()
|
return netErr.Timeout()
|
||||||
}
|
}
|
||||||
return strings.Contains(err.Error(), "deadline exceeded") ||
|
return strings.Contains(err.Error(), "deadline exceeded") ||
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
package jsontypes
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
@ -8,7 +8,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Prompter struct {
|
type prompter struct {
|
||||||
reader *bufio.Reader
|
reader *bufio.Reader
|
||||||
output io.Writer
|
output io.Writer
|
||||||
tty *os.File // non-nil if we opened /dev/tty
|
tty *os.File // non-nil if we opened /dev/tty
|
||||||
@ -21,20 +21,15 @@ type Prompter struct {
|
|||||||
|
|
||||||
// newPrompter creates a prompter. If the JSON input comes from stdin, we open
|
// newPrompter creates a prompter. If the JSON input comes from stdin, we open
|
||||||
// /dev/tty for interactive prompts so they don't conflict.
|
// /dev/tty for interactive prompts so they don't conflict.
|
||||||
func NewPrompter(inputIsStdin, anonymous bool) (*Prompter, error) {
|
func newPrompter(inputIsStdin bool) (*prompter, error) {
|
||||||
p := &Prompter{output: os.Stderr}
|
p := &prompter{output: os.Stderr}
|
||||||
if inputIsStdin {
|
if inputIsStdin {
|
||||||
if anonymous {
|
tty, err := os.Open("/dev/tty")
|
||||||
// No prompts needed — use a closed reader that returns EOF
|
if err != nil {
|
||||||
p.reader = bufio.NewReader(strings.NewReader(""))
|
return nil, fmt.Errorf("cannot open /dev/tty for prompts (input is stdin): %w", err)
|
||||||
} else {
|
|
||||||
tty, err := os.Open("/dev/tty")
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("cannot open /dev/tty for prompts (input is stdin): %w", err)
|
|
||||||
}
|
|
||||||
p.tty = tty
|
|
||||||
p.reader = bufio.NewReader(tty)
|
|
||||||
}
|
}
|
||||||
|
p.tty = tty
|
||||||
|
p.reader = bufio.NewReader(tty)
|
||||||
} else {
|
} else {
|
||||||
p.reader = bufio.NewReader(os.Stdin)
|
p.reader = bufio.NewReader(os.Stdin)
|
||||||
}
|
}
|
||||||
@ -42,7 +37,7 @@ func NewPrompter(inputIsStdin, anonymous bool) (*Prompter, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// loadAnswers reads prior answers from a file to use as defaults.
|
// loadAnswers reads prior answers from a file to use as defaults.
|
||||||
func (p *Prompter) LoadAnswers(path string) {
|
func (p *prompter) loadAnswers(path string) {
|
||||||
data, err := os.ReadFile(path)
|
data, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
@ -59,7 +54,7 @@ func (p *Prompter) LoadAnswers(path string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// saveAnswers writes this session's answers to a file.
|
// saveAnswers writes this session's answers to a file.
|
||||||
func (p *Prompter) SaveAnswers(path string) error {
|
func (p *prompter) saveAnswers(path string) error {
|
||||||
if len(p.answers) == 0 {
|
if len(p.answers) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@ -67,7 +62,7 @@ func (p *Prompter) SaveAnswers(path string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// nextPrior returns the next prior answer if available, or empty string.
|
// nextPrior returns the next prior answer if available, or empty string.
|
||||||
func (p *Prompter) nextPrior() string {
|
func (p *prompter) nextPrior() string {
|
||||||
if p.priorIdx < len(p.priorAnswers) {
|
if p.priorIdx < len(p.priorAnswers) {
|
||||||
answer := p.priorAnswers[p.priorIdx]
|
answer := p.priorAnswers[p.priorIdx]
|
||||||
p.priorIdx++
|
p.priorIdx++
|
||||||
@ -77,11 +72,11 @@ func (p *Prompter) nextPrior() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// record saves an answer for later writing.
|
// record saves an answer for later writing.
|
||||||
func (p *Prompter) record(answer string) {
|
func (p *prompter) record(answer string) {
|
||||||
p.answers = append(p.answers, answer)
|
p.answers = append(p.answers, answer)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Prompter) Close() {
|
func (p *prompter) close() {
|
||||||
if p.tty != nil {
|
if p.tty != nil {
|
||||||
p.tty.Close()
|
p.tty.Close()
|
||||||
}
|
}
|
||||||
@ -90,7 +85,7 @@ func (p *Prompter) Close() {
|
|||||||
// ask presents a prompt with a default and valid options. Returns the chosen
|
// ask presents a prompt with a default and valid options. Returns the chosen
|
||||||
// option (lowercase). Options should be lowercase; the default is shown in
|
// option (lowercase). Options should be lowercase; the default is shown in
|
||||||
// uppercase in the hint.
|
// uppercase in the hint.
|
||||||
func (p *Prompter) ask(prompt, defaultOpt string, options []string) string {
|
func (p *prompter) ask(prompt, defaultOpt string, options []string) string {
|
||||||
// Override default with prior answer if available
|
// Override default with prior answer if available
|
||||||
if prior := p.nextPrior(); prior != "" {
|
if prior := p.nextPrior(); prior != "" {
|
||||||
for _, o := range options {
|
for _, o := range options {
|
||||||
@ -131,21 +126,14 @@ func (p *Prompter) ask(prompt, defaultOpt string, options []string) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// askMapOrName presents a combined map/struct+name prompt. Shows [Default/m].
|
// askMapOrName presents a combined map/struct+name prompt.
|
||||||
// Accepts: 'm' or 'map' → returns "m", a name starting with an uppercase
|
func (p *prompter) askMapOrName(prompt, defaultVal string) string {
|
||||||
// letter → returns the name, empty → returns the default. Anything else
|
|
||||||
// re-prompts.
|
|
||||||
//
|
|
||||||
// Prior answers are interpreted generously: "s" (old struct answer) is treated
|
|
||||||
// as "accept the default struct name", "m" as map, and uppercase names as-is.
|
|
||||||
func (p *Prompter) askMapOrName(prompt, defaultVal string) string {
|
|
||||||
if prior := p.nextPrior(); prior != "" {
|
if prior := p.nextPrior(); prior != "" {
|
||||||
if prior == "m" || prior == "map" {
|
if prior == "m" || prior == "map" {
|
||||||
defaultVal = prior
|
defaultVal = prior
|
||||||
} else if len(prior) > 0 && prior[0] >= 'A' && prior[0] <= 'Z' {
|
} else if len(prior) > 0 && prior[0] >= 'A' && prior[0] <= 'Z' {
|
||||||
defaultVal = prior
|
defaultVal = prior
|
||||||
}
|
}
|
||||||
// Old-format answers like "s" → keep the inferred default (treat as "accept")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
hint := defaultVal + "/m"
|
hint := defaultVal + "/m"
|
||||||
@ -178,16 +166,11 @@ func (p *Prompter) askMapOrName(prompt, defaultVal string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// askTypeName presents a prompt for a type name with a suggested default.
|
// askTypeName presents a prompt for a type name with a suggested default.
|
||||||
// Accepts names starting with an uppercase letter.
|
func (p *prompter) askTypeName(prompt, defaultVal string) string {
|
||||||
//
|
|
||||||
// Prior answers are interpreted generously: old-format answers that don't
|
|
||||||
// start with uppercase are treated as "accept the default".
|
|
||||||
func (p *Prompter) askTypeName(prompt, defaultVal string) string {
|
|
||||||
if prior := p.nextPrior(); prior != "" {
|
if prior := p.nextPrior(); prior != "" {
|
||||||
if len(prior) > 0 && prior[0] >= 'A' && prior[0] <= 'Z' {
|
if len(prior) > 0 && prior[0] >= 'A' && prior[0] <= 'Z' {
|
||||||
defaultVal = prior
|
defaultVal = prior
|
||||||
}
|
}
|
||||||
// Old-format answers → keep the inferred default (treat as "accept")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for {
|
for {
|
||||||
@ -209,26 +192,3 @@ func (p *Prompter) askTypeName(prompt, defaultVal string) string {
|
|||||||
fmt.Fprintf(p.output, " Enter a TypeName (starting with uppercase)\n")
|
fmt.Fprintf(p.output, " Enter a TypeName (starting with uppercase)\n")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// askFreeform presents a prompt with a suggested default. Returns user input
|
|
||||||
// or the default if they just press enter.
|
|
||||||
func (p *Prompter) askFreeform(prompt, defaultVal string) string {
|
|
||||||
// Override default with prior answer if available
|
|
||||||
if prior := p.nextPrior(); prior != "" {
|
|
||||||
defaultVal = prior
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Fprintf(p.output, "%s [%s] ", prompt, defaultVal)
|
|
||||||
line, err := p.reader.ReadString('\n')
|
|
||||||
if err != nil {
|
|
||||||
p.record(defaultVal)
|
|
||||||
return defaultVal
|
|
||||||
}
|
|
||||||
line = strings.TrimSpace(line)
|
|
||||||
if line == "" {
|
|
||||||
p.record(defaultVal)
|
|
||||||
return defaultVal
|
|
||||||
}
|
|
||||||
p.record(line)
|
|
||||||
return line
|
|
||||||
}
|
|
||||||
182
tools/jsontypes/cmd/jsonpaths/resolver.go
Normal file
182
tools/jsontypes/cmd/jsonpaths/resolver.go
Normal file
@ -0,0 +1,182 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/therootcompany/golib/tools/jsontypes"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newCLIResolver returns a jsontypes.Resolver that uses a prompter for terminal I/O.
|
||||||
|
func newCLIResolver(p *prompter) jsontypes.Resolver {
|
||||||
|
return func(d *jsontypes.Decision) error {
|
||||||
|
switch d.Kind {
|
||||||
|
case jsontypes.DecideMapOrStruct:
|
||||||
|
return cliMapOrStruct(p, d)
|
||||||
|
case jsontypes.DecideTypeName:
|
||||||
|
return cliTypeName(p, d)
|
||||||
|
case jsontypes.DecideTupleOrList:
|
||||||
|
return cliTupleOrList(p, d)
|
||||||
|
case jsontypes.DecideUnifyShapes:
|
||||||
|
return cliUnifyShapes(p, d)
|
||||||
|
case jsontypes.DecideShapeName:
|
||||||
|
return cliShapeName(p, d)
|
||||||
|
case jsontypes.DecideNameCollision:
|
||||||
|
return cliNameCollision(p, d)
|
||||||
|
default:
|
||||||
|
d.Response = d.Default
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cliMapOrStruct(p *prompter, d *jsontypes.Decision) error {
|
||||||
|
fmt.Fprintf(p.output, "\nAt %s\n", d.Path)
|
||||||
|
fmt.Fprintf(p.output, " Object with %d keys:\n", len(d.Fields))
|
||||||
|
for _, f := range d.Fields {
|
||||||
|
fmt.Fprintf(p.output, " %s: %s\n", f.Name, f.Preview)
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultVal := d.Default.Name
|
||||||
|
if d.Default.IsMap {
|
||||||
|
defaultVal = "m"
|
||||||
|
}
|
||||||
|
|
||||||
|
answer := p.askMapOrName("Struct name (or 'm' for map)?", defaultVal)
|
||||||
|
if answer == "m" {
|
||||||
|
d.Response = jsontypes.Response{IsMap: true}
|
||||||
|
} else {
|
||||||
|
d.Response = jsontypes.Response{Name: answer}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func cliTypeName(p *prompter, d *jsontypes.Decision) error {
|
||||||
|
fmt.Fprintf(p.output, "\nAt %s\n", d.Path)
|
||||||
|
fmt.Fprintf(p.output, " Struct with %d fields:\n", len(d.Fields))
|
||||||
|
for _, f := range d.Fields {
|
||||||
|
fmt.Fprintf(p.output, " %s: %s\n", f.Name, f.Preview)
|
||||||
|
}
|
||||||
|
|
||||||
|
name := p.askTypeName("Name for this type?", d.Default.Name)
|
||||||
|
d.Response = jsontypes.Response{Name: name}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func cliTupleOrList(p *prompter, d *jsontypes.Decision) error {
|
||||||
|
fmt.Fprintf(p.output, "\nAt %s\n", d.Path)
|
||||||
|
fmt.Fprintf(p.output, " Short array with %d elements of mixed types:\n", len(d.Elements))
|
||||||
|
for _, e := range d.Elements {
|
||||||
|
fmt.Fprintf(p.output, " [%d]: %s\n", e.Index, e.Preview)
|
||||||
|
}
|
||||||
|
|
||||||
|
choice := p.ask("Is this a [l]ist or a [t]uple?", "l", []string{"l", "t"})
|
||||||
|
d.Response = jsontypes.Response{IsTuple: choice == "t"}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func cliUnifyShapes(p *prompter, d *jsontypes.Decision) error {
|
||||||
|
const maxFields = 8
|
||||||
|
|
||||||
|
totalInstances := 0
|
||||||
|
for _, s := range d.Shapes {
|
||||||
|
totalInstances += s.Instances
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(p.output, "\nAt %s — %d shapes (%d instances):\n",
|
||||||
|
d.Path, len(d.Shapes), totalInstances)
|
||||||
|
|
||||||
|
if len(d.SharedFields) > 0 {
|
||||||
|
preview := d.SharedFields
|
||||||
|
if len(preview) > maxFields {
|
||||||
|
preview = preview[:maxFields]
|
||||||
|
}
|
||||||
|
fmt.Fprintf(p.output, " shared fields (%d): %s", len(d.SharedFields), strings.Join(preview, ", "))
|
||||||
|
if len(d.SharedFields) > maxFields {
|
||||||
|
fmt.Fprintf(p.output, ", ...")
|
||||||
|
}
|
||||||
|
fmt.Fprintln(p.output)
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(p.output, " no shared fields\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
shownShapes := d.Shapes
|
||||||
|
if len(shownShapes) > 5 {
|
||||||
|
shownShapes = shownShapes[:5]
|
||||||
|
}
|
||||||
|
for _, s := range shownShapes {
|
||||||
|
if len(s.UniqueFields) == 0 {
|
||||||
|
fmt.Fprintf(p.output, " shape %d (%d instances): no unique fields\n", s.Index+1, s.Instances)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
preview := s.UniqueFields
|
||||||
|
if len(preview) > maxFields {
|
||||||
|
preview = preview[:maxFields]
|
||||||
|
}
|
||||||
|
fmt.Fprintf(p.output, " shape %d (%d instances): +%d unique: %s",
|
||||||
|
s.Index+1, s.Instances, len(s.UniqueFields), strings.Join(preview, ", "))
|
||||||
|
if len(s.UniqueFields) > maxFields {
|
||||||
|
fmt.Fprintf(p.output, ", ...")
|
||||||
|
}
|
||||||
|
fmt.Fprintln(p.output)
|
||||||
|
}
|
||||||
|
if len(d.Shapes) > 5 {
|
||||||
|
fmt.Fprintf(p.output, " ... and %d more shapes\n", len(d.Shapes)-5)
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultChoice := "s"
|
||||||
|
if d.Default.IsNewType {
|
||||||
|
defaultChoice = "d"
|
||||||
|
}
|
||||||
|
|
||||||
|
var choice string
|
||||||
|
for {
|
||||||
|
choice = p.ask("[s]ame type? [d]ifferent? show [f]ull list?",
|
||||||
|
defaultChoice, []string{"s", "d", "f"})
|
||||||
|
if choice != "f" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
for _, s := range d.Shapes {
|
||||||
|
fmt.Fprintf(p.output, " Shape %d (%d instances): %s\n",
|
||||||
|
s.Index+1, s.Instances, strings.Join(s.Fields, ", "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
d.Response = jsontypes.Response{IsNewType: choice == "d"}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func cliShapeName(p *prompter, d *jsontypes.Decision) error {
|
||||||
|
fmt.Fprintf(p.output, " Shape %d: %s\n",
|
||||||
|
d.ShapeIndex+1, strings.Join(fieldNames(d.Fields), ", "))
|
||||||
|
|
||||||
|
name := p.askTypeName(
|
||||||
|
fmt.Sprintf(" Name for shape %d?", d.ShapeIndex+1), d.Default.Name)
|
||||||
|
d.Response = jsontypes.Response{Name: name}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func cliNameCollision(p *prompter, d *jsontypes.Decision) error {
|
||||||
|
fmt.Fprintf(p.output, " Type %q already exists with overlapping fields: %s\n",
|
||||||
|
d.Default.Name, strings.Join(d.ExistingFields, ", "))
|
||||||
|
|
||||||
|
choice := p.ask(
|
||||||
|
fmt.Sprintf(" [e]xtend %q with merged fields, or use a [d]ifferent name?", d.Default.Name),
|
||||||
|
"e", []string{"e", "d"},
|
||||||
|
)
|
||||||
|
if choice == "e" {
|
||||||
|
d.Response = jsontypes.Response{Extend: true, Name: d.Default.Name}
|
||||||
|
} else {
|
||||||
|
name := p.askTypeName(" New name?", d.Default.Name)
|
||||||
|
d.Response = jsontypes.Response{Name: name}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func fieldNames(fields []jsontypes.FieldSummary) []string {
|
||||||
|
names := make([]string, len(fields))
|
||||||
|
for i, f := range fields {
|
||||||
|
names[i] = f.Name
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
}
|
||||||
@ -7,63 +7,46 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// decideMapOrStruct determines whether an object is a map or struct.
|
// 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 {
|
func (a *Analyzer) decideMapOrStruct(path string, obj map[string]any) bool {
|
||||||
isMap, confident := looksLikeMap(obj)
|
isMap, confident := looksLikeMap(obj)
|
||||||
if a.anonymous {
|
|
||||||
|
// Skip resolver when heuristics are confident and we're not in askTypes mode
|
||||||
|
if a.autonomous || (!a.askTypes && confident) {
|
||||||
return isMap
|
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)
|
inferred := inferTypeName(path)
|
||||||
if inferred == "" {
|
if inferred == "" {
|
||||||
a.typeCounter++
|
a.typeCounter++
|
||||||
inferred = fmt.Sprintf("Struct%d", a.typeCounter)
|
inferred = fmt.Sprintf("Struct%d", a.typeCounter)
|
||||||
}
|
}
|
||||||
|
|
||||||
defaultVal := inferred
|
def := Response{Name: inferred}
|
||||||
if confident && heuristicMap {
|
if confident && isMap {
|
||||||
defaultVal = "m"
|
def = Response{IsMap: true}
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Fprintf(a.Prompter.output, "\nAt %s\n", shortPath(path))
|
d := &Decision{
|
||||||
fmt.Fprintf(a.Prompter.output, " Object with %d keys:\n", len(keys))
|
Kind: DecideMapOrStruct,
|
||||||
for _, k := range keys {
|
Path: shortPath(path),
|
||||||
fmt.Fprintf(a.Prompter.output, " %s: %s\n", k, valueSummary(obj[k]))
|
Default: def,
|
||||||
|
Fields: objectToFieldSummaries(obj),
|
||||||
}
|
}
|
||||||
|
|
||||||
answer := a.Prompter.askMapOrName("Struct name (or 'm' for map)?", defaultVal)
|
if err := a.resolver(d); err != nil {
|
||||||
if answer == "m" {
|
return isMap
|
||||||
|
}
|
||||||
|
|
||||||
|
if d.Response.IsMap {
|
||||||
a.pendingTypeName = ""
|
a.pendingTypeName = ""
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
a.pendingTypeName = answer
|
a.pendingTypeName = d.Response.Name
|
||||||
return false
|
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
|
// decideTypeName determines the struct type name, using inference and optionally
|
||||||
// prompting the user.
|
// asking the resolver.
|
||||||
func (a *Analyzer) decideTypeName(path string, obj map[string]any) string {
|
func (a *Analyzer) decideTypeName(path string, obj map[string]any) string {
|
||||||
// Check if we've already named a type with this exact shape
|
// Check if we've already named a type with this exact shape
|
||||||
sig := shapeSignature(obj)
|
sig := shapeSignature(obj)
|
||||||
@ -74,7 +57,7 @@ func (a *Analyzer) decideTypeName(path string, obj map[string]any) string {
|
|||||||
|
|
||||||
newFields := fieldSet(obj)
|
newFields := fieldSet(obj)
|
||||||
|
|
||||||
// Consume pending name from askTypes combined prompt
|
// Consume pending name from combined map/struct prompt
|
||||||
if a.pendingTypeName != "" {
|
if a.pendingTypeName != "" {
|
||||||
name := a.pendingTypeName
|
name := a.pendingTypeName
|
||||||
a.pendingTypeName = ""
|
a.pendingTypeName = ""
|
||||||
@ -87,26 +70,19 @@ func (a *Analyzer) decideTypeName(path string, obj map[string]any) string {
|
|||||||
inferred = fmt.Sprintf("Struct%d", a.typeCounter)
|
inferred = fmt.Sprintf("Struct%d", a.typeCounter)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default and anonymous modes: auto-resolve without prompting
|
// Default and autonomous modes: auto-resolve without asking
|
||||||
if !a.askTypes {
|
if !a.askTypes {
|
||||||
return a.autoResolveTypeName(path, inferred, newFields, sig)
|
return a.autoResolveTypeName(path, inferred, newFields, sig)
|
||||||
}
|
}
|
||||||
|
|
||||||
// askTypes mode: show fields and prompt for name
|
// askTypes mode: ask the resolver
|
||||||
keys := sortedKeys(obj)
|
name := a.resolveNameViaResolver(path, inferred, newFields, sig, 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
|
return name
|
||||||
}
|
}
|
||||||
|
|
||||||
// autoResolveTypeName registers or resolves a type name without prompting.
|
// autoResolveTypeName registers or resolves a type name without prompting.
|
||||||
// On collision, tries the parent-prefix strategy; if that also collides, prompts
|
// On collision, tries the parent-prefix strategy; if that also collides, prompts
|
||||||
// (unless anonymous, in which case it uses a numbered fallback).
|
// (unless autonomous, in which case it uses a numbered fallback).
|
||||||
func (a *Analyzer) autoResolveTypeName(path, name string, newFields map[string]string, sig string) string {
|
func (a *Analyzer) autoResolveTypeName(path, name string, newFields map[string]string, sig string) string {
|
||||||
existing, taken := a.typesByName[name]
|
existing, taken := a.typesByName[name]
|
||||||
if !taken {
|
if !taken {
|
||||||
@ -130,12 +106,12 @@ func (a *Analyzer) autoResolveTypeName(path, name string, newFields map[string]s
|
|||||||
return a.registerType(sig, alt, newFields)
|
return a.registerType(sig, alt, newFields)
|
||||||
}
|
}
|
||||||
// Parent strategy also taken
|
// Parent strategy also taken
|
||||||
if a.anonymous {
|
if a.autonomous {
|
||||||
a.typeCounter++
|
a.typeCounter++
|
||||||
return a.registerType(sig, fmt.Sprintf("%s%d", name, a.typeCounter), newFields)
|
return a.registerType(sig, fmt.Sprintf("%s%d", name, a.typeCounter), newFields)
|
||||||
}
|
}
|
||||||
// Last resort: prompt
|
// Last resort: ask the resolver
|
||||||
return a.promptName(path, alt, newFields, sig)
|
return a.resolveNameViaResolver(path, alt, newFields, sig, nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -158,17 +134,29 @@ func (a *Analyzer) resolveTypeName(path, name string, newFields map[string]strin
|
|||||||
a.knownTypes[sig] = existing
|
a.knownTypes[sig] = existing
|
||||||
return name
|
return name
|
||||||
default:
|
default:
|
||||||
return a.promptName(path, name, newFields, sig)
|
return a.resolveNameViaResolver(path, name, newFields, sig, nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// promptName asks for a type name and handles collisions with existing types.
|
// resolveNameViaResolver asks the resolver for a type name and handles
|
||||||
// Pre-resolves the suggested name so the user sees a valid default.
|
// collisions. obj may be nil if field summaries are unavailable.
|
||||||
func (a *Analyzer) promptName(path, suggested string, newFields map[string]string, sig string) string {
|
func (a *Analyzer) resolveNameViaResolver(path, suggested string, newFields map[string]string, sig string, obj map[string]any) string {
|
||||||
suggested = a.preResolveCollision(path, suggested, newFields, sig)
|
suggested = a.preResolveCollision(path, suggested, newFields)
|
||||||
|
|
||||||
for {
|
for {
|
||||||
name := a.Prompter.askFreeform("Name for this type?", suggested)
|
d := &Decision{
|
||||||
|
Kind: DecideTypeName,
|
||||||
|
Path: shortPath(path),
|
||||||
|
Default: Response{Name: suggested},
|
||||||
|
Fields: objectToFieldSummaries(obj),
|
||||||
|
}
|
||||||
|
if err := a.resolver(d); err != nil {
|
||||||
|
return a.registerType(sig, suggested, newFields)
|
||||||
|
}
|
||||||
|
name := d.Response.Name
|
||||||
|
if name == "" {
|
||||||
|
name = suggested
|
||||||
|
}
|
||||||
|
|
||||||
existing, taken := a.typesByName[name]
|
existing, taken := a.typesByName[name]
|
||||||
if !taken {
|
if !taken {
|
||||||
@ -181,19 +169,22 @@ func (a *Analyzer) promptName(path, suggested string, newFields map[string]strin
|
|||||||
a.knownTypes[sig] = existing
|
a.knownTypes[sig] = existing
|
||||||
return name
|
return name
|
||||||
case relSubset, relSuperset:
|
case relSubset, relSuperset:
|
||||||
fmt.Fprintf(a.Prompter.output, " Extending existing type %q (merging fields)\n", name)
|
|
||||||
merged := mergeFieldSets(existing.fields, newFields)
|
merged := mergeFieldSets(existing.fields, newFields)
|
||||||
existing.fields = merged
|
existing.fields = merged
|
||||||
a.knownTypes[sig] = existing
|
a.knownTypes[sig] = existing
|
||||||
return name
|
return name
|
||||||
case relOverlap:
|
case relOverlap:
|
||||||
fmt.Fprintf(a.Prompter.output, " Type %q already exists with overlapping fields: %s\n",
|
cd := &Decision{
|
||||||
name, fieldList(existing.fields))
|
Kind: DecideNameCollision,
|
||||||
choice := a.Prompter.ask(
|
Path: shortPath(path),
|
||||||
fmt.Sprintf(" [e]xtend %q with merged fields, or use a [d]ifferent name?", name),
|
Default: Response{Name: name, Extend: true},
|
||||||
"e", []string{"e", "d"},
|
Fields: objectToFieldSummaries(obj),
|
||||||
)
|
ExistingFields: fieldListSlice(existing.fields),
|
||||||
if choice == "e" {
|
}
|
||||||
|
if err := a.resolver(cd); err != nil {
|
||||||
|
return a.registerType(sig, suggested, newFields)
|
||||||
|
}
|
||||||
|
if cd.Response.Extend {
|
||||||
merged := mergeFieldSets(existing.fields, newFields)
|
merged := mergeFieldSets(existing.fields, newFields)
|
||||||
existing.fields = merged
|
existing.fields = merged
|
||||||
a.knownTypes[sig] = existing
|
a.knownTypes[sig] = existing
|
||||||
@ -202,8 +193,6 @@ func (a *Analyzer) promptName(path, suggested string, newFields map[string]strin
|
|||||||
suggested = a.suggestAlternativeName(path, name)
|
suggested = a.suggestAlternativeName(path, name)
|
||||||
continue
|
continue
|
||||||
case relDisjoint:
|
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)
|
suggested = a.suggestAlternativeName(path, name)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@ -211,9 +200,8 @@ func (a *Analyzer) promptName(path, suggested string, newFields map[string]strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
// preResolveCollision checks if the suggested name collides with an existing
|
// 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
|
// type that can't be auto-merged. If so, returns an alternative name.
|
||||||
// suggested name.
|
func (a *Analyzer) preResolveCollision(path, suggested string, newFields map[string]string) string {
|
||||||
func (a *Analyzer) preResolveCollision(path, suggested string, newFields map[string]string, sig string) string {
|
|
||||||
existing, taken := a.typesByName[suggested]
|
existing, taken := a.typesByName[suggested]
|
||||||
if !taken {
|
if !taken {
|
||||||
return suggested
|
return suggested
|
||||||
@ -224,10 +212,7 @@ func (a *Analyzer) preResolveCollision(path, suggested string, newFields map[str
|
|||||||
case relEqual, relSubset, relSuperset:
|
case relEqual, relSubset, relSuperset:
|
||||||
return suggested
|
return suggested
|
||||||
default:
|
default:
|
||||||
alt := a.suggestAlternativeName(path, suggested)
|
return a.suggestAlternativeName(path, suggested)
|
||||||
fmt.Fprintf(a.Prompter.output, " (type %q already exists with different fields, suggesting %q)\n",
|
|
||||||
suggested, alt)
|
|
||||||
return alt
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -371,12 +356,6 @@ func kindsCompatible(a, b string) bool {
|
|||||||
return false
|
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 {
|
func mergeFieldSets(a, b map[string]string) map[string]string {
|
||||||
merged := make(map[string]string, len(a)+len(b))
|
merged := make(map[string]string, len(a)+len(b))
|
||||||
for k, v := range a {
|
for k, v := range a {
|
||||||
@ -392,30 +371,32 @@ func mergeFieldSets(a, b map[string]string) map[string]string {
|
|||||||
return merged
|
return merged
|
||||||
}
|
}
|
||||||
|
|
||||||
func fieldList(fields map[string]string) string {
|
// decideTupleOrList asks whether a short mixed-type array is a tuple or list.
|
||||||
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 {
|
func (a *Analyzer) decideTupleOrList(path string, arr []any) bool {
|
||||||
if a.anonymous {
|
if a.autonomous {
|
||||||
return false // default to list
|
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))
|
elems := make([]ElementSummary, len(arr))
|
||||||
for i, v := range arr {
|
for i, v := range arr {
|
||||||
fmt.Fprintf(a.Prompter.output, " [%d]: %s\n", i, valueSummary(v))
|
elems[i] = ElementSummary{
|
||||||
|
Index: i,
|
||||||
|
Kind: kindOf(v),
|
||||||
|
Preview: valueSummary(v),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
choice := a.Prompter.ask(
|
|
||||||
"Is this a [l]ist or a [t]uple?",
|
d := &Decision{
|
||||||
"l", []string{"l", "t"},
|
Kind: DecideTupleOrList,
|
||||||
)
|
Path: shortPath(path),
|
||||||
return choice == "t"
|
Default: Response{IsTuple: false},
|
||||||
|
Elements: elems,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := a.resolver(d); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return d.Response.IsTuple
|
||||||
}
|
}
|
||||||
|
|
||||||
// valueSummary returns a short human-readable summary of a JSON value.
|
// valueSummary returns a short human-readable summary of a JSON value.
|
||||||
@ -454,6 +435,33 @@ func valueSummary(v any) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// objectToFieldSummaries builds FieldSummary entries from a JSON object.
|
||||||
|
func objectToFieldSummaries(obj map[string]any) []FieldSummary {
|
||||||
|
if obj == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
keys := sortedKeys(obj)
|
||||||
|
summaries := make([]FieldSummary, len(keys))
|
||||||
|
for i, k := range keys {
|
||||||
|
summaries[i] = FieldSummary{
|
||||||
|
Name: k,
|
||||||
|
Kind: kindOf(obj[k]),
|
||||||
|
Preview: valueSummary(obj[k]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return summaries
|
||||||
|
}
|
||||||
|
|
||||||
|
// fieldListSlice returns sorted field names from a field set.
|
||||||
|
func fieldListSlice(fields map[string]string) []string {
|
||||||
|
keys := make([]string, 0, len(fields))
|
||||||
|
for k := range fields {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
return keys
|
||||||
|
}
|
||||||
|
|
||||||
func fieldSet(obj map[string]any) map[string]string {
|
func fieldSet(obj map[string]any) map[string]string {
|
||||||
fs := make(map[string]string, len(obj))
|
fs := make(map[string]string, len(obj))
|
||||||
for k, v := range obj {
|
for k, v := range obj {
|
||||||
|
|||||||
@ -35,12 +35,16 @@
|
|||||||
// dec.UseNumber()
|
// dec.UseNumber()
|
||||||
// dec.Decode(&data)
|
// dec.Decode(&data)
|
||||||
//
|
//
|
||||||
// a, _ := jsontypes.NewAnalyzer(false, true, false) // anonymous mode
|
// a := jsontypes.New(jsontypes.AnalyzerConfig{})
|
||||||
// defer a.Close()
|
|
||||||
//
|
|
||||||
// paths := jsontypes.FormatPaths(a.Analyze(".", data))
|
// paths := jsontypes.FormatPaths(a.Analyze(".", data))
|
||||||
// fmt.Print(jsontypes.GenerateTypeScript(paths))
|
// fmt.Print(jsontypes.GenerateTypeScript(paths))
|
||||||
//
|
//
|
||||||
|
// Or use the one-shot API:
|
||||||
|
//
|
||||||
|
// out, _ := jsontypes.AutoGenerate(jsonBytes, jsontypes.Options{
|
||||||
|
// Format: jsontypes.FormatTypeScript,
|
||||||
|
// })
|
||||||
|
//
|
||||||
// # AI tool use
|
// # AI tool use
|
||||||
//
|
//
|
||||||
// This package is designed to be callable as an AI skill. Given a JSON
|
// This package is designed to be callable as an AI skill. Given a JSON
|
||||||
|
|||||||
@ -57,7 +57,7 @@ func parsePath(path string) []segment {
|
|||||||
return segments
|
return segments
|
||||||
}
|
}
|
||||||
|
|
||||||
// formatPaths converts fully-annotated flat paths into the display format where:
|
// FormatPaths converts fully-annotated flat paths into the display format where:
|
||||||
// - The root type appears alone on the first line (no leading dot)
|
// - The root type appears alone on the first line (no leading dot)
|
||||||
// - Each type introduction gets its own line
|
// - Each type introduction gets its own line
|
||||||
// - Type annotations only appear on the rightmost (new) segment of each line
|
// - Type annotations only appear on the rightmost (new) segment of each line
|
||||||
|
|||||||
@ -1,8 +1,6 @@
|
|||||||
package jsontypes
|
package jsontypes
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
|
||||||
"io"
|
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
@ -132,18 +130,15 @@ func TestDifferentTypesEndToEnd(t *testing.T) {
|
|||||||
}
|
}
|
||||||
obj := map[string]any{"items": arr, "count": jsonNum("4"), "status": "ok"}
|
obj := map[string]any{"items": arr, "count": jsonNum("4"), "status": "ok"}
|
||||||
|
|
||||||
a := &Analyzer{
|
a := New(AnalyzerConfig{
|
||||||
Prompter: &Prompter{
|
// Root has 3 field-like keys → confident struct, no resolver call needed.
|
||||||
reader: bufio.NewReader(strings.NewReader("")),
|
// Then items[] has 2 shapes → unification: different types, then names.
|
||||||
output: io.Discard,
|
Resolver: scriptedResolver(
|
||||||
// Root has 3 field-like keys → confident struct, no prompt needed.
|
Response{IsNewType: true}, // different types for shapes
|
||||||
// Then items[] has 2 shapes → unification prompt: "d" for different,
|
Response{Name: "FileField"}, // name for shape 1
|
||||||
// then names for each shape.
|
Response{Name: "FeatureField"}, // name for shape 2
|
||||||
priorAnswers: []string{"d", "FileField", "FeatureField"},
|
),
|
||||||
},
|
})
|
||||||
knownTypes: make(map[string]*structType),
|
|
||||||
typesByName: make(map[string]*structType),
|
|
||||||
}
|
|
||||||
rawPaths := a.Analyze(".", obj)
|
rawPaths := a.Analyze(".", obj)
|
||||||
formatted := FormatPaths(rawPaths)
|
formatted := FormatPaths(rawPaths)
|
||||||
|
|
||||||
|
|||||||
@ -49,7 +49,7 @@ func (u *goUnion) wrapperTypeName() string {
|
|||||||
return u.name
|
return u.name
|
||||||
}
|
}
|
||||||
|
|
||||||
// generateGoStructs converts formatted flat paths into Go struct definitions
|
// GenerateGoStructs converts formatted flat paths into Go struct definitions
|
||||||
// with json tags. When multiple types share an array/map position, it generates
|
// with json tags. When multiple types share an array/map position, it generates
|
||||||
// a sealed interface, discriminator function, and wrapper type.
|
// a sealed interface, discriminator function, and wrapper type.
|
||||||
func GenerateGoStructs(paths []string) string {
|
func GenerateGoStructs(paths []string) string {
|
||||||
|
|||||||
@ -1,10 +1,8 @@
|
|||||||
package jsontypes
|
package jsontypes
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@ -650,15 +648,13 @@ func TestGoStructUnionEndToEnd(t *testing.T) {
|
|||||||
}
|
}
|
||||||
obj := map[string]any{"items": arr, "count": jsonNum("4"), "status": "ok"}
|
obj := map[string]any{"items": arr, "count": jsonNum("4"), "status": "ok"}
|
||||||
|
|
||||||
a := &Analyzer{
|
a := New(AnalyzerConfig{
|
||||||
Prompter: &Prompter{
|
Resolver: scriptedResolver(
|
||||||
reader: bufio.NewReader(strings.NewReader("")),
|
Response{IsNewType: true}, // different types for shapes
|
||||||
output: io.Discard,
|
Response{Name: "FileField"}, // name for shape 1
|
||||||
priorAnswers: []string{"d", "FileField", "FeatureField"},
|
Response{Name: "FeatureField"}, // name for shape 2
|
||||||
},
|
),
|
||||||
knownTypes: make(map[string]*structType),
|
})
|
||||||
typesByName: make(map[string]*structType),
|
|
||||||
}
|
|
||||||
rawPaths := a.Analyze(".", obj)
|
rawPaths := a.Analyze(".", obj)
|
||||||
formatted := FormatPaths(rawPaths)
|
formatted := FormatPaths(rawPaths)
|
||||||
goCode := GenerateGoStructs(formatted)
|
goCode := GenerateGoStructs(formatted)
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// generateJSDoc converts formatted flat paths into JSDoc @typedef annotations.
|
// GenerateJSDoc converts formatted flat paths into JSDoc @typedef annotations.
|
||||||
func GenerateJSDoc(paths []string) string {
|
func GenerateJSDoc(paths []string) string {
|
||||||
types, _ := buildGoTypes(paths)
|
types, _ := buildGoTypes(paths)
|
||||||
if len(types) == 0 {
|
if len(types) == 0 {
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// generateJSONSchema converts formatted flat paths into a JSON Schema (draft 2020-12) document.
|
// GenerateJSONSchema converts formatted flat paths into a JSON Schema (draft 2020-12) document.
|
||||||
func GenerateJSONSchema(paths []string) string {
|
func GenerateJSONSchema(paths []string) string {
|
||||||
types, _ := buildGoTypes(paths)
|
types, _ := buildGoTypes(paths)
|
||||||
|
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// generatePython converts formatted flat paths into Python TypedDict definitions.
|
// GeneratePython converts formatted flat paths into Python TypedDict definitions.
|
||||||
func GeneratePython(paths []string) string {
|
func GeneratePython(paths []string) string {
|
||||||
types, _ := buildGoTypes(paths)
|
types, _ := buildGoTypes(paths)
|
||||||
if len(types) == 0 {
|
if len(types) == 0 {
|
||||||
|
|||||||
142
tools/jsontypes/resolver.go
Normal file
142
tools/jsontypes/resolver.go
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
package jsontypes
|
||||||
|
|
||||||
|
// DecisionKind identifies the type of decision being requested.
|
||||||
|
type DecisionKind int
|
||||||
|
|
||||||
|
const (
|
||||||
|
// DecideMapOrStruct asks whether a JSON object is a map (dynamic keys)
|
||||||
|
// or a struct (fixed fields).
|
||||||
|
// Relevant Decision fields: Fields.
|
||||||
|
// Response: set IsMap=true, or set Name to a PascalCase type name.
|
||||||
|
DecideMapOrStruct DecisionKind = iota
|
||||||
|
|
||||||
|
// DecideTypeName asks what a struct type should be called.
|
||||||
|
// Relevant Decision fields: Fields.
|
||||||
|
// Response: set Name to a PascalCase type name.
|
||||||
|
DecideTypeName
|
||||||
|
|
||||||
|
// DecideTupleOrList asks whether a short mixed-type array is a tuple
|
||||||
|
// (fixed positional types) or a homogeneous list.
|
||||||
|
// Relevant Decision fields: Elements.
|
||||||
|
// Response: set IsTuple.
|
||||||
|
DecideTupleOrList
|
||||||
|
|
||||||
|
// DecideUnifyShapes asks whether multiple object shapes at the same
|
||||||
|
// JSON position represent the same type (with optional fields) or
|
||||||
|
// different types.
|
||||||
|
// Relevant Decision fields: Shapes, SharedFields.
|
||||||
|
// Response: set IsNewType to treat each shape as a separate type.
|
||||||
|
DecideUnifyShapes
|
||||||
|
|
||||||
|
// DecideShapeName asks what a specific shape variant should be called
|
||||||
|
// when the user chose "different types" for a UnifyShapes decision.
|
||||||
|
// Relevant Decision fields: ShapeIndex, Fields.
|
||||||
|
// Response: set Name to a PascalCase type name.
|
||||||
|
DecideShapeName
|
||||||
|
|
||||||
|
// DecideNameCollision asks what to do when a chosen type name is
|
||||||
|
// already registered with overlapping but incompatible fields.
|
||||||
|
// Relevant Decision fields: Fields (new), ExistingFields.
|
||||||
|
// Response: set Extend=true to merge fields, or set Name to a
|
||||||
|
// different PascalCase type name.
|
||||||
|
DecideNameCollision
|
||||||
|
)
|
||||||
|
|
||||||
|
// Decision represents a question posed during JSON analysis.
|
||||||
|
// The Kind field determines which context fields are populated.
|
||||||
|
type Decision struct {
|
||||||
|
Kind DecisionKind
|
||||||
|
Path string // JSON path being analyzed
|
||||||
|
Default Response // heuristic suggestion
|
||||||
|
|
||||||
|
// Fields describes the object's keys and value types.
|
||||||
|
// Populated for MapOrStruct, TypeName, ShapeName, NameCollision.
|
||||||
|
Fields []FieldSummary
|
||||||
|
|
||||||
|
// Elements describes array element values.
|
||||||
|
// Populated for TupleOrList.
|
||||||
|
Elements []ElementSummary
|
||||||
|
|
||||||
|
// Shapes describes multiple object shapes at the same position.
|
||||||
|
// Populated for UnifyShapes.
|
||||||
|
Shapes []ShapeSummary
|
||||||
|
|
||||||
|
// SharedFields lists field names common to all shapes.
|
||||||
|
// Populated for UnifyShapes.
|
||||||
|
SharedFields []string
|
||||||
|
|
||||||
|
// ShapeIndex identifies which shape is being named (0-based).
|
||||||
|
// Populated for ShapeName.
|
||||||
|
ShapeIndex int
|
||||||
|
|
||||||
|
// ExistingFields lists the fields of the already-registered type
|
||||||
|
// whose name collides with the requested name.
|
||||||
|
// Populated for NameCollision.
|
||||||
|
ExistingFields []string
|
||||||
|
|
||||||
|
// Response is set by the Resolver to communicate the decision.
|
||||||
|
Response Response
|
||||||
|
}
|
||||||
|
|
||||||
|
// Response carries the answer to a Decision.
|
||||||
|
// Which fields are meaningful depends on the Decision.Kind.
|
||||||
|
type Response struct {
|
||||||
|
// Name is a PascalCase type name.
|
||||||
|
// Used by MapOrStruct (when not a map), TypeName, ShapeName,
|
||||||
|
// and NameCollision (when not extending).
|
||||||
|
Name string
|
||||||
|
|
||||||
|
// IsMap indicates the object should be treated as a map.
|
||||||
|
// Used by MapOrStruct.
|
||||||
|
IsMap bool
|
||||||
|
|
||||||
|
// IsTuple indicates the array is a tuple.
|
||||||
|
// Used by TupleOrList.
|
||||||
|
IsTuple bool
|
||||||
|
|
||||||
|
// IsNewType indicates each shape should be a separate type rather than
|
||||||
|
// unifying into one type with optional fields.
|
||||||
|
// Used by UnifyShapes. Zero value (false) means unify into one type.
|
||||||
|
IsNewType bool
|
||||||
|
|
||||||
|
// Extend indicates the existing type should be extended with merged
|
||||||
|
// fields rather than choosing a new name.
|
||||||
|
// Used by NameCollision.
|
||||||
|
Extend bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// FieldSummary describes a single field in a JSON object.
|
||||||
|
type FieldSummary struct {
|
||||||
|
Name string // JSON field name
|
||||||
|
Kind string // "string", "number", "bool", "null", "object", "array"
|
||||||
|
Preview string // human-readable value summary
|
||||||
|
}
|
||||||
|
|
||||||
|
// ElementSummary describes a single element in a JSON array.
|
||||||
|
type ElementSummary struct {
|
||||||
|
Index int
|
||||||
|
Kind string
|
||||||
|
Preview string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShapeSummary describes one shape group in a multi-shape decision.
|
||||||
|
type ShapeSummary struct {
|
||||||
|
Index int // 0-based shape index
|
||||||
|
Instances int // how many objects have this shape
|
||||||
|
Fields []string // all field names in this shape
|
||||||
|
UniqueFields []string // fields unique to this shape (not in other shapes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolver is called during analysis when a decision is needed.
|
||||||
|
// The resolver reads the Decision's context fields and sets
|
||||||
|
// Decision.Response before returning. If an error is returned,
|
||||||
|
// heuristic defaults are used for that decision.
|
||||||
|
//
|
||||||
|
// To accept the heuristic default: d.Response = d.Default.
|
||||||
|
type Resolver func(d *Decision) error
|
||||||
|
|
||||||
|
// defaultResolver accepts heuristic defaults for all decisions.
|
||||||
|
func defaultResolver(d *Decision) error {
|
||||||
|
d.Response = d.Default
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@ -5,7 +5,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// generateSQL converts formatted flat paths into SQL CREATE TABLE statements.
|
// GenerateSQL converts formatted flat paths into SQL CREATE TABLE statements.
|
||||||
// Nested structs become separate tables with foreign key relationships.
|
// Nested structs become separate tables with foreign key relationships.
|
||||||
// Arrays of structs get a join table or FK pointing back to the parent.
|
// Arrays of structs get a join table or FK pointing back to the parent.
|
||||||
func GenerateSQL(paths []string) string {
|
func GenerateSQL(paths []string) string {
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// generateTypedef converts formatted flat paths into a JSON Typedef (RFC 8927) document.
|
// GenerateTypedef converts formatted flat paths into a JSON Typedef (RFC 8927) document.
|
||||||
func GenerateTypedef(paths []string) string {
|
func GenerateTypedef(paths []string) string {
|
||||||
types, _ := buildGoTypes(paths)
|
types, _ := buildGoTypes(paths)
|
||||||
|
|
||||||
@ -92,6 +92,7 @@ func goTypeToJTDInner(goTyp string, typeMap map[string]goType, defs map[string]a
|
|||||||
case "string":
|
case "string":
|
||||||
return map[string]any{"type": "string"}
|
return map[string]any{"type": "string"}
|
||||||
case "int64":
|
case "int64":
|
||||||
|
// JTD (RFC 8927) has no int64 type; int32 is the largest integer type available.
|
||||||
return map[string]any{"type": "int32"}
|
return map[string]any{"type": "int32"}
|
||||||
case "float64":
|
case "float64":
|
||||||
return map[string]any{"type": "float64"}
|
return map[string]any{"type": "float64"}
|
||||||
|
|||||||
@ -140,11 +140,7 @@ func analyzeAndFormat(t *testing.T, jsonStr string) []string {
|
|||||||
if err := dec.Decode(&data); err != nil {
|
if err := dec.Decode(&data); err != nil {
|
||||||
t.Fatalf("invalid test JSON: %v", err)
|
t.Fatalf("invalid test JSON: %v", err)
|
||||||
}
|
}
|
||||||
a, err := NewAnalyzer(false, true, false)
|
a := New(AnalyzerConfig{})
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("NewAnalyzer: %v", err)
|
|
||||||
}
|
|
||||||
defer a.Close()
|
|
||||||
rawPaths := a.Analyze(".", data)
|
rawPaths := a.Analyze(".", data)
|
||||||
return FormatPaths(rawPaths)
|
return FormatPaths(rawPaths)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// generateTypeScript converts formatted flat paths into TypeScript interface definitions.
|
// GenerateTypeScript converts formatted flat paths into TypeScript interface definitions.
|
||||||
func GenerateTypeScript(paths []string) string {
|
func GenerateTypeScript(paths []string) string {
|
||||||
types, _ := buildGoTypes(paths)
|
types, _ := buildGoTypes(paths)
|
||||||
if len(types) == 0 {
|
if len(types) == 0 {
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// generateZod converts formatted flat paths into Zod schema definitions.
|
// GenerateZod converts formatted flat paths into Zod schema definitions.
|
||||||
func GenerateZod(paths []string) string {
|
func GenerateZod(paths []string) string {
|
||||||
types, _ := buildGoTypes(paths)
|
types, _ := buildGoTypes(paths)
|
||||||
if len(types) == 0 {
|
if len(types) == 0 {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user