From cdadf914598861eec2e200792cd042979b635f73 Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Sat, 7 Mar 2026 21:38:43 -0700 Subject: [PATCH] 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 --- tools/jsontypes/analyzer.go | 197 ++++++------ tools/jsontypes/analyzer_test.go | 283 ++++++++---------- tools/jsontypes/auto.go | 117 ++++++++ tools/jsontypes/cmd/jsonpaths/README.md | 11 +- tools/jsontypes/cmd/jsonpaths/main.go | 75 +++-- tools/jsontypes/{ => cmd/jsonpaths}/prompt.go | 76 ++--- tools/jsontypes/cmd/jsonpaths/resolver.go | 182 +++++++++++ tools/jsontypes/decisions.go | 204 +++++++------ tools/jsontypes/doc.go | 10 +- tools/jsontypes/format.go | 2 +- tools/jsontypes/format_test.go | 23 +- tools/jsontypes/gostruct.go | 2 +- tools/jsontypes/gostruct_test.go | 18 +- tools/jsontypes/jsdoc.go | 2 +- tools/jsontypes/jsonschema.go | 2 +- tools/jsontypes/python.go | 2 +- tools/jsontypes/resolver.go | 142 +++++++++ tools/jsontypes/sql.go | 2 +- tools/jsontypes/typedef.go | 3 +- tools/jsontypes/typedef_test.go | 6 +- tools/jsontypes/typescript.go | 2 +- tools/jsontypes/zod.go | 2 +- 22 files changed, 848 insertions(+), 515 deletions(-) create mode 100644 tools/jsontypes/auto.go rename tools/jsontypes/{ => cmd/jsonpaths}/prompt.go (63%) create mode 100644 tools/jsontypes/cmd/jsonpaths/resolver.go create mode 100644 tools/jsontypes/resolver.go diff --git a/tools/jsontypes/analyzer.go b/tools/jsontypes/analyzer.go index 73a7cff..f780931 100644 --- a/tools/jsontypes/analyzer.go +++ b/tools/jsontypes/analyzer.go @@ -7,17 +7,34 @@ import ( "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 { - Prompter *Prompter - anonymous bool - askTypes bool + resolver Resolver + autonomous bool + askTypes bool + typeCounter int - // knownTypes maps shape signature → type name - knownTypes map[string]*structType - // typesByName maps type name → structType for collision detection + knownTypes map[string]*structType typesByName map[string]*structType - // pendingTypeName is set by the combined map/struct+name prompt - // and consumed by decideTypeName to avoid double-prompting + // pendingTypeName is set by decideMapOrStruct and consumed by + // decideTypeName to avoid double-prompting. pendingTypeName string } @@ -27,30 +44,27 @@ type structType struct { } type shapeGroup struct { - sig string fields []string members []map[string]any } -func NewAnalyzer(inputIsStdin, anonymous, askTypes bool) (*Analyzer, error) { - p, err := NewPrompter(inputIsStdin, anonymous) - if err != nil { - return nil, err +// New creates an Analyzer with the given configuration. +func New(cfg AnalyzerConfig) *Analyzer { + r := cfg.Resolver + autonomous := r == nil + if r == nil { + r = defaultResolver } return &Analyzer{ - Prompter: p, - anonymous: anonymous, - askTypes: askTypes, + resolver: r, + autonomous: autonomous, + askTypes: cfg.AskTypes, knownTypes: make(map[string]*structType), typesByName: make(map[string]*structType), - }, nil + } } -func (a *Analyzer) Close() { - a.Prompter.Close() -} - -// analyze traverses a JSON value depth-first and returns annotated flat paths. +// Analyze traverses a JSON value depth-first and returns annotated flat paths. func (a *Analyzer) Analyze(path string, val any) []string { switch v := val.(type) { 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 { - keyName := a.decideKeyName(path, obj) + keyName := inferKeyName(obj) // Collect all values and group by shape for type unification 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) - if a.isTupleCandidate(arr) { + if isTupleCandidate(arr) { isTuple := a.decideTupleOrList(path, arr) if isTuple { 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) } else { g := &shapeGroup{ - sig: sig, fields: sortedKeys(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) } - // Multiple shapes — in anonymous mode default to same type - if a.anonymous { + // Multiple shapes — in autonomous mode default to same type + if a.autonomous { return a.analyzeAsStructMulti(path, objects) } 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) } -// promptTypeUnification presents shape groups to the user and asks if they -// are the same type (with optional fields) or different types. +// promptTypeUnification presents shape groups and asks if they are the same +// type (with optional fields) or different types. func (a *Analyzer) promptTypeUnification(path string, groups map[string]*shapeGroup, groupOrder []string) []string { - const maxFields = 8 - // Compute shared and unique fields across all shapes 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", - shortPath(path), len(groupOrder), totalInstances) - - // 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 { + // Build shape summaries for the resolver + shapes := make([]ShapeSummary, len(groupOrder)) + for i, sig := range groupOrder { g := groups[sig] - unique := uniquePerShape[sig] - if len(unique) == 0 { - fmt.Fprintf(a.Prompter.output, " shape %d (%d instances): no unique fields\n", i+1, len(g.members)) - continue + shapes[i] = ShapeSummary{ + Index: i, + Instances: len(g.members), + 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 - // fields, default to "different". Ubiquitous fields (id, name, *_at, etc.) - // don't count as meaningful shared fields. + // fields, default to "different". meaningfulShared := 0 for _, f := range shared { if !isUbiquitousField(f) { @@ -372,34 +345,27 @@ func (a *Analyzer) promptTypeUnification(path string, groups map[string]*shapeGr for _, sig := range groupOrder { totalUnique += len(uniquePerShape[sig]) } - defaultChoice := "s" - if totalUnique >= 2*meaningfulShared { - defaultChoice = "d" + defaultIsNewType := totalUnique >= 2*meaningfulShared + + d := &Decision{ + Kind: DecideUnifyShapes, + Path: shortPath(path), + Default: Response{IsNewType: defaultIsNewType}, + Shapes: shapes, + SharedFields: shared, } - // Combined prompt: same/different/show full list - var choice string - for { - choice = a.Prompter.ask( - "[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, ", ")) - } + // Pre-collect all instances for the common "same type" path. + var all []map[string]any + for _, sig := range groupOrder { + all = append(all, groups[sig].members...) } - if choice == "s" { - // Same type — analyze with all instances for field unification - var all []map[string]any - for _, sig := range groupOrder { - all = append(all, groups[sig].members...) - } + if err := a.resolver(d); err != nil { + return a.analyzeAsStructMulti(path, all) + } + + if !d.Response.IsNewType { return a.analyzeAsStructMulti(path, all) } @@ -412,20 +378,29 @@ func (a *Analyzer) promptTypeUnification(path string, groups map[string]*shapeGr a.typeCounter++ inferred = fmt.Sprintf("Struct%d", a.typeCounter) } - // Pre-resolve collision so the suggested name is valid merged := mergeObjects(g.members) newFields := fieldSet(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", - i+1, len(g.members), strings.Join(g.fields, ", ")) - name := a.Prompter.askTypeName( - fmt.Sprintf(" Name for shape %d?", i+1), inferred) - names[i] = name + sd := &Decision{ + Kind: DecideShapeName, + Path: shortPath(path), + Default: Response{Name: inferred}, + 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 - a.registerType(shapeSig, name, newFields) + a.registerType(shapeSig, names[i], newFields) } // 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: // 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 { return false } diff --git a/tools/jsontypes/analyzer_test.go b/tools/jsontypes/analyzer_test.go index e22a4df..99d12a9 100644 --- a/tools/jsontypes/analyzer_test.go +++ b/tools/jsontypes/analyzer_test.go @@ -1,28 +1,39 @@ package jsontypes import ( - "bufio" "encoding/json" - "io" - "os" "sort" "strings" "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 { t.Helper() - a := &Analyzer{ - Prompter: &Prompter{ - reader: nil, - output: os.Stderr, - }, - anonymous: true, - knownTypes: make(map[string]*structType), - typesByName: make(map[string]*structType), + return New(AnalyzerConfig{}) +} + +// scriptedResolver creates a Resolver that returns responses in order. +// After all responses are consumed, it accepts defaults. +func scriptedResolver(responses ...Response) Resolver { + i := 0 + 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 { @@ -36,14 +47,13 @@ func TestAnalyzePrimitive(t *testing.T) { a := testAnalyzer(t) tests := []struct { name string - json string want string }{ - {"null", "", ".{null}"}, - {"bool", "", ".{bool}"}, - {"int", "", ".{int}"}, - {"float", "", ".{float}"}, - {"string", "", ".{string}"}, + {"null", ".{null}"}, + {"bool", ".{bool}"}, + {"int", ".{int}"}, + {"float", ".{float}"}, + {"string", ".{string}"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -427,15 +437,19 @@ func TestAnalyzeFullSample(t *testing.T) { assertPaths(t, paths, want) } -// TestDifferentTypesPromptsForNames verifies that when the user chooses -// "different" for multiple shapes at the same path: -// 1. They are prompted to name each shape group -// 2. All names are collected BEFORE recursing into children -// 3. The named types appear in the final output -func TestDifferentTypesPromptsForNames(t *testing.T) { - // Simulate: a Room has items[] containing two distinct shapes, each with - // a nested "meta" object. Names should be asked for both shapes before - // the meta objects are analyzed. +// TestDifferentTypesViaResolver verifies that when the resolver returns +// IsNewType=true for multiple shapes at the same path: +// 1. Shape names are requested via DecideShapeName +// 2. The named types appear in the final output +func TestDifferentTypesViaResolver(t *testing.T) { + a := New(AnalyzerConfig{ + Resolver: scriptedResolver( + Response{IsNewType: true}, // different types for shapes + Response{Name: "FileField"}, // name for shape 1 + Response{Name: "FeatureField"}, // name for shape 2 + ), + }) + arr := []any{ // Shape 1: has "filename" and "is_required" 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")}}, } - 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)) - // Verify both named types appear in the paths hasFileField := false hasFeatureField := false 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 ")) } - // 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 formatted := FormatPaths(paths) foundFileField := false @@ -510,109 +502,64 @@ func TestDifferentTypesPromptsForNames(t *testing.T) { } } -// TestCombinedPromptShowsTypeName verifies the default-mode prompt shows -// [Root/m] (inferred name + map option), not [s/m] or [S/m]. -func TestCombinedPromptShowsTypeName(t *testing.T) { - var output strings.Builder - a := &Analyzer{ - Prompter: &Prompter{ - reader: bufio.NewReader(strings.NewReader("")), - output: &output, - priorAnswers: []string{"Root"}, // accept default +// TestDecideMapOrStructDefault verifies that the library sends +// the inferred type name as the default in DecideMapOrStruct decisions. +func TestDecideMapOrStructDefault(t *testing.T) { + var captured *Decision + a := New(AnalyzerConfig{ + Resolver: func(d *Decision) error { + if d.Kind == DecideMapOrStruct && captured == nil { + cp := *d + 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 - arr := []any{ - map[string]any{"name": "Alice", "x": jsonNum("1")}, - map[string]any{"name": "Bob", "y": jsonNum("2")}, + obj := map[string]any{ + "errors": []any{}, + "rooms": []any{map[string]any{"name": "foo"}}, } - obj := map[string]any{"items": arr} - paths := sortPaths(a.Analyze(".", obj)) + a.Analyze(".", obj) - // Should have Root type (from "s" → accept default) and Item type - // unified as same type (from "s" → same) - hasRoot := false - for _, p := range paths { - if strings.Contains(p, "{Root}") { - hasRoot = true - break - } + if captured == nil { + t.Fatal("expected DecideMapOrStruct decision") } - if !hasRoot { - t.Errorf("expected {Root} type (old 's' should accept default), got:\n %s", - strings.Join(paths, "\n ")) + if captured.Default.Name != "Root" { + t.Errorf("expected default name %q, got %q", "Root", captured.Default.Name) } } // TestDefaultDifferentWhenUniqueFieldsDominate verifies that when shapes share // 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) { - // Two shapes sharing only "slug" (ubiquitous) with 2+ unique fields each. - // With no prior answer for same/different, the default should be "d". - // Then we need type names for each shape. - // Shape ordering is insertion order: shape 1 = filename,is_required,slug; shape 2 = archived,feature,slug - a := testInteractiveAnalyzer(t, []string{ - "Root", // root object has 1 key → not confident, prompts for struct/map - "d", // accept default (should be "d" because unique >> meaningful shared) - "FileField", // name for shape 1 (filename, is_required, slug) - "FeatureField", // name for shape 2 (archived, feature, slug) + var unifyDecision *Decision + a := New(AnalyzerConfig{ + Resolver: func(d *Decision) error { + if d.Kind == DecideUnifyShapes && unifyDecision == nil { + cp := *d + unifyDecision = &cp + } + // For shape unification, accept the default; for other decisions + // 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{ @@ -622,6 +569,13 @@ func TestDefaultDifferentWhenUniqueFieldsDominate(t *testing.T) { obj := map[string]any{"items": arr} 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 hasFile := false hasFeature := false @@ -640,14 +594,23 @@ func TestDefaultDifferentWhenUniqueFieldsDominate(t *testing.T) { } // 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) { - // Two shapes sharing "email", "phone", "address" (non-ubiquitous) with - // only 1 unique field each. unique (2) < 2 * meaningful shared (3), so - // default should be "s". - a := testInteractiveAnalyzer(t, []string{ - "Root", // root object has 1 key → not confident, prompts for struct/map - "s", // accept default (should be "s") + var unifyDecision *Decision + a := New(AnalyzerConfig{ + Resolver: func(d *Decision) error { + if d.Kind == DecideUnifyShapes && unifyDecision == nil { + cp := *d + unifyDecision = &cp + } + switch d.Kind { + case DecideMapOrStruct: + d.Response = Response{Name: "Root"} + default: + d.Response = d.Default + } + return nil + }, }) arr := []any{ @@ -657,7 +620,14 @@ func TestDefaultSameWhenMeaningfulFieldsShared(t *testing.T) { obj := map[string]any{"people": arr} 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 for _, p := range paths { 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 func jsonNum(s string) json.Number { diff --git a/tools/jsontypes/auto.go b/tools/jsontypes/auto.go new file mode 100644 index 0000000..8b3c38c --- /dev/null +++ b/tools/jsontypes/auto.go @@ -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, +} diff --git a/tools/jsontypes/cmd/jsonpaths/README.md b/tools/jsontypes/cmd/jsonpaths/README.md index b0ab3a2..9da91b4 100644 --- a/tools/jsontypes/cmd/jsonpaths/README.md +++ b/tools/jsontypes/cmd/jsonpaths/README.md @@ -178,14 +178,19 @@ The core logic is available as a Go package: ```go import "github.com/therootcompany/golib/tools/jsontypes" -a, _ := jsontypes.NewAnalyzer(false, true, false) // anonymous mode -defer a.Close() +// One-shot: parse JSON and generate code in one call +out, err := jsontypes.AutoGenerate(jsonBytes, jsontypes.Options{ + Format: jsontypes.FormatTypeScript, +}) + +// Or step by step: +a := jsontypes.New(jsontypes.AnalyzerConfig{}) var data any // ... json.Decode with UseNumber() ... 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) diff --git a/tools/jsontypes/cmd/jsonpaths/main.go b/tools/jsontypes/cmd/jsonpaths/main.go index 30c23b4..8cff372 100644 --- a/tools/jsontypes/cmd/jsonpaths/main.go +++ b/tools/jsontypes/cmd/jsonpaths/main.go @@ -2,9 +2,11 @@ package main import ( "bufio" + "bytes" "crypto/tls" "encoding/base64" "encoding/json" + "errors" "flag" "fmt" "io" @@ -92,6 +94,12 @@ func main() { 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 baseName string // base filename for .paths and .answers files inputIsStdin := true @@ -158,46 +166,34 @@ func main() { os.Exit(1) } - a, err := jsontypes.NewAnalyzer(inputIsStdin, *anonymous, *askTypes) - if err != nil { - fmt.Fprintf(os.Stderr, "error: %v\n", err) - os.Exit(1) - } - defer a.Close() - - // Load prior answers if available - if baseName != "" && !*anonymous { - a.Prompter.LoadAnswers(baseName + ".answers") + var resolver jsontypes.Resolver + var pr *prompter + if !*anonymous { + pr, err = newPrompter(inputIsStdin) + if err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } + defer pr.close() + if baseName != "" { + pr.loadAnswers(baseName + ".answers") + } + resolver = newCLIResolver(pr) } + a := jsontypes.New(jsontypes.AnalyzerConfig{ + Resolver: resolver, + AskTypes: *askTypes, + }) rawPaths := a.Analyze(".", data) formatted := jsontypes.FormatPaths(rawPaths) - switch *format { - case "go": - fmt.Print(jsontypes.GenerateGoStructs(formatted)) - 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) + out, err := jsontypes.Generate(outFormat, formatted) + if err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) os.Exit(1) } + fmt.Print(out) // Save outputs if baseName != "" { @@ -206,9 +202,9 @@ func main() { fmt.Fprintf(os.Stderr, "warning: could not write %s: %v\n", pathsFile, err) } - if !*anonymous { + if !*anonymous && pr != nil { 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) } } @@ -291,8 +287,9 @@ func isSensitiveParam(name string) bool { } func fetchOrCache(rawURL string, timeout time.Duration, noCache bool, extraHeaders http.Header) (io.ReadCloser, error) { + path := slugify(rawURL) + if !noCache { - path := slugify(rawURL) if info, err := os.Stat(path); err == nil && info.Size() > 0 { f, err := os.Open(path) if err == nil { @@ -311,7 +308,6 @@ func fetchOrCache(rawURL string, timeout time.Duration, noCache bool, extraHeade return body, nil } - path := slugify(rawURL) data, err := io.ReadAll(body) body.Close() 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) } - 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) { @@ -391,7 +387,8 @@ func fetchURL(url string, timeout time.Duration, extraHeaders http.Header) (io.R } 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 strings.Contains(err.Error(), "deadline exceeded") || diff --git a/tools/jsontypes/prompt.go b/tools/jsontypes/cmd/jsonpaths/prompt.go similarity index 63% rename from tools/jsontypes/prompt.go rename to tools/jsontypes/cmd/jsonpaths/prompt.go index a00fd5e..587561b 100644 --- a/tools/jsontypes/prompt.go +++ b/tools/jsontypes/cmd/jsonpaths/prompt.go @@ -1,4 +1,4 @@ -package jsontypes +package main import ( "bufio" @@ -8,7 +8,7 @@ import ( "strings" ) -type Prompter struct { +type prompter struct { reader *bufio.Reader output io.Writer 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 // /dev/tty for interactive prompts so they don't conflict. -func NewPrompter(inputIsStdin, anonymous bool) (*Prompter, error) { - p := &Prompter{output: os.Stderr} +func newPrompter(inputIsStdin bool) (*prompter, error) { + p := &prompter{output: os.Stderr} if inputIsStdin { - if anonymous { - // No prompts needed — use a closed reader that returns EOF - p.reader = bufio.NewReader(strings.NewReader("")) - } 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) + 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) } else { 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. -func (p *Prompter) LoadAnswers(path string) { +func (p *prompter) loadAnswers(path string) { data, err := os.ReadFile(path) if err != nil { return @@ -59,7 +54,7 @@ func (p *Prompter) LoadAnswers(path string) { } // 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 { return nil } @@ -67,7 +62,7 @@ func (p *Prompter) SaveAnswers(path string) error { } // 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) { answer := p.priorAnswers[p.priorIdx] p.priorIdx++ @@ -77,11 +72,11 @@ func (p *Prompter) nextPrior() string { } // 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) } -func (p *Prompter) Close() { +func (p *prompter) close() { if p.tty != nil { p.tty.Close() } @@ -90,7 +85,7 @@ func (p *Prompter) Close() { // ask presents a prompt with a default and valid options. Returns the chosen // option (lowercase). Options should be lowercase; the default is shown in // 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 if prior := p.nextPrior(); prior != "" { 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]. -// Accepts: 'm' or 'map' → returns "m", a name starting with an uppercase -// 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 { +// askMapOrName presents a combined map/struct+name prompt. +func (p *prompter) askMapOrName(prompt, defaultVal string) string { if prior := p.nextPrior(); prior != "" { if prior == "m" || prior == "map" { defaultVal = prior } else if len(prior) > 0 && prior[0] >= 'A' && prior[0] <= 'Z' { defaultVal = prior } - // Old-format answers like "s" → keep the inferred default (treat as "accept") } 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. -// Accepts names starting with an uppercase letter. -// -// 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 { +func (p *prompter) askTypeName(prompt, defaultVal string) string { if prior := p.nextPrior(); prior != "" { if len(prior) > 0 && prior[0] >= 'A' && prior[0] <= 'Z' { defaultVal = prior } - // Old-format answers → keep the inferred default (treat as "accept") } for { @@ -209,26 +192,3 @@ func (p *Prompter) askTypeName(prompt, defaultVal string) string { 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 -} diff --git a/tools/jsontypes/cmd/jsonpaths/resolver.go b/tools/jsontypes/cmd/jsonpaths/resolver.go new file mode 100644 index 0000000..58f3623 --- /dev/null +++ b/tools/jsontypes/cmd/jsonpaths/resolver.go @@ -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 +} diff --git a/tools/jsontypes/decisions.go b/tools/jsontypes/decisions.go index 9572d1f..1c5b7ee 100644 --- a/tools/jsontypes/decisions.go +++ b/tools/jsontypes/decisions.go @@ -7,63 +7,46 @@ import ( ) // decideMapOrStruct determines whether an object is a map or struct. -// In anonymous mode, uses heuristics silently. -// Otherwise, shows a combined prompt: enter a TypeName or 'm' for map. -// In default mode, confident heuristic maps skip the prompt. -// In askTypes mode, the prompt is always shown. func (a *Analyzer) decideMapOrStruct(path string, obj map[string]any) bool { isMap, confident := looksLikeMap(obj) - if a.anonymous { + + // Skip resolver when heuristics are confident and we're not in askTypes mode + if a.autonomous || (!a.askTypes && confident) { return isMap } - // Default mode: skip prompt when heuristics are confident - if !a.askTypes && confident { - return isMap - } - - return a.promptMapOrStructWithName(path, obj, isMap, confident) -} - -// promptMapOrStructWithName shows the object's fields and asks a combined question. -// The user can type 'm' or 'map' for a map, a name starting with a capital letter -// for a struct type, or press Enter to accept the default. -func (a *Analyzer) promptMapOrStructWithName(path string, obj map[string]any, heuristicMap, confident bool) bool { - keys := sortedKeys(obj) - inferred := inferTypeName(path) if inferred == "" { a.typeCounter++ inferred = fmt.Sprintf("Struct%d", a.typeCounter) } - defaultVal := inferred - if confident && heuristicMap { - defaultVal = "m" + def := Response{Name: inferred} + if confident && isMap { + def = Response{IsMap: true} } - fmt.Fprintf(a.Prompter.output, "\nAt %s\n", shortPath(path)) - fmt.Fprintf(a.Prompter.output, " Object with %d keys:\n", len(keys)) - for _, k := range keys { - fmt.Fprintf(a.Prompter.output, " %s: %s\n", k, valueSummary(obj[k])) + d := &Decision{ + Kind: DecideMapOrStruct, + Path: shortPath(path), + Default: def, + Fields: objectToFieldSummaries(obj), } - answer := a.Prompter.askMapOrName("Struct name (or 'm' for map)?", defaultVal) - if answer == "m" { + if err := a.resolver(d); err != nil { + return isMap + } + + if d.Response.IsMap { a.pendingTypeName = "" return true } - a.pendingTypeName = answer + a.pendingTypeName = d.Response.Name return false } -// decideKeyName infers the map key type from the keys. -func (a *Analyzer) decideKeyName(_ string, obj map[string]any) string { - return inferKeyName(obj) -} - // decideTypeName determines the struct type name, using inference and optionally -// prompting the user. +// asking the resolver. func (a *Analyzer) decideTypeName(path string, obj map[string]any) string { // Check if we've already named a type with this exact shape sig := shapeSignature(obj) @@ -74,7 +57,7 @@ func (a *Analyzer) decideTypeName(path string, obj map[string]any) string { newFields := fieldSet(obj) - // Consume pending name from askTypes combined prompt + // Consume pending name from combined map/struct prompt if a.pendingTypeName != "" { name := 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) } - // Default and anonymous modes: auto-resolve without prompting + // Default and autonomous modes: auto-resolve without asking if !a.askTypes { return a.autoResolveTypeName(path, inferred, newFields, sig) } - // askTypes mode: show fields and prompt for name - keys := sortedKeys(obj) - fmt.Fprintf(a.Prompter.output, "\nAt %s\n", shortPath(path)) - fmt.Fprintf(a.Prompter.output, " Struct with %d fields:\n", len(keys)) - for _, k := range keys { - fmt.Fprintf(a.Prompter.output, " %s: %s\n", k, valueSummary(obj[k])) - } - - name := a.promptName(path, inferred, newFields, sig) + // askTypes mode: ask the resolver + name := a.resolveNameViaResolver(path, inferred, newFields, sig, obj) return name } // autoResolveTypeName registers or resolves a type name without prompting. // On collision, tries the parent-prefix strategy; if that also collides, prompts -// (unless anonymous, in which case it uses a numbered fallback). +// (unless autonomous, in which case it uses a numbered fallback). func (a *Analyzer) autoResolveTypeName(path, name string, newFields map[string]string, sig string) string { existing, taken := a.typesByName[name] if !taken { @@ -130,12 +106,12 @@ func (a *Analyzer) autoResolveTypeName(path, name string, newFields map[string]s return a.registerType(sig, alt, newFields) } // Parent strategy also taken - if a.anonymous { + if a.autonomous { a.typeCounter++ return a.registerType(sig, fmt.Sprintf("%s%d", name, a.typeCounter), newFields) } - // Last resort: prompt - return a.promptName(path, alt, newFields, sig) + // Last resort: ask the resolver + 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 return name 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. -// Pre-resolves the suggested name so the user sees a valid default. -func (a *Analyzer) promptName(path, suggested string, newFields map[string]string, sig string) string { - suggested = a.preResolveCollision(path, suggested, newFields, sig) +// resolveNameViaResolver asks the resolver for a type name and handles +// collisions. obj may be nil if field summaries are unavailable. +func (a *Analyzer) resolveNameViaResolver(path, suggested string, newFields map[string]string, sig string, obj map[string]any) string { + suggested = a.preResolveCollision(path, suggested, newFields) 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] if !taken { @@ -181,19 +169,22 @@ func (a *Analyzer) promptName(path, suggested string, newFields map[string]strin a.knownTypes[sig] = existing return name case relSubset, relSuperset: - fmt.Fprintf(a.Prompter.output, " Extending existing type %q (merging fields)\n", name) merged := mergeFieldSets(existing.fields, newFields) existing.fields = merged a.knownTypes[sig] = existing return name case relOverlap: - fmt.Fprintf(a.Prompter.output, " Type %q already exists with overlapping fields: %s\n", - name, fieldList(existing.fields)) - choice := a.Prompter.ask( - fmt.Sprintf(" [e]xtend %q with merged fields, or use a [d]ifferent name?", name), - "e", []string{"e", "d"}, - ) - if choice == "e" { + cd := &Decision{ + Kind: DecideNameCollision, + Path: shortPath(path), + Default: Response{Name: name, Extend: true}, + Fields: objectToFieldSummaries(obj), + ExistingFields: fieldListSlice(existing.fields), + } + if err := a.resolver(cd); err != nil { + return a.registerType(sig, suggested, newFields) + } + if cd.Response.Extend { merged := mergeFieldSets(existing.fields, newFields) existing.fields = merged a.knownTypes[sig] = existing @@ -202,8 +193,6 @@ func (a *Analyzer) promptName(path, suggested string, newFields map[string]strin suggested = a.suggestAlternativeName(path, name) continue case relDisjoint: - fmt.Fprintf(a.Prompter.output, " Type %q already exists with different fields: %s\n", - name, fieldList(existing.fields)) suggested = a.suggestAlternativeName(path, name) continue } @@ -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 -// type that can't be auto-merged. If so, prints a warning and returns a new -// suggested name. -func (a *Analyzer) preResolveCollision(path, suggested string, newFields map[string]string, sig string) string { +// type that can't be auto-merged. If so, returns an alternative name. +func (a *Analyzer) preResolveCollision(path, suggested string, newFields map[string]string) string { existing, taken := a.typesByName[suggested] if !taken { return suggested @@ -224,10 +212,7 @@ func (a *Analyzer) preResolveCollision(path, suggested string, newFields map[str case relEqual, relSubset, relSuperset: return suggested default: - alt := a.suggestAlternativeName(path, suggested) - fmt.Fprintf(a.Prompter.output, " (type %q already exists with different fields, suggesting %q)\n", - suggested, alt) - return alt + return a.suggestAlternativeName(path, suggested) } } @@ -371,12 +356,6 @@ func kindsCompatible(a, b string) bool { return false } -// fieldsOverlap returns true if one field set is a subset or superset of the other. -func fieldsOverlap(a, b map[string]string) bool { - rel := fieldRelation(a, b) - return rel == relEqual || rel == relSubset || rel == relSuperset -} - func mergeFieldSets(a, b map[string]string) map[string]string { merged := make(map[string]string, len(a)+len(b)) for k, v := range a { @@ -392,30 +371,32 @@ func mergeFieldSets(a, b map[string]string) map[string]string { return merged } -func fieldList(fields map[string]string) string { - keys := make([]string, 0, len(fields)) - for k := range fields { - keys = append(keys, k) - } - sort.Strings(keys) - return strings.Join(keys, ", ") -} - -// decideTupleOrList asks the user if a short mixed-type array is a tuple or list. +// decideTupleOrList asks whether a short mixed-type array is a tuple or list. func (a *Analyzer) decideTupleOrList(path string, arr []any) bool { - if a.anonymous { + if a.autonomous { 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 { - 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?", - "l", []string{"l", "t"}, - ) - return choice == "t" + + d := &Decision{ + Kind: DecideTupleOrList, + Path: shortPath(path), + 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. @@ -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 { fs := make(map[string]string, len(obj)) for k, v := range obj { diff --git a/tools/jsontypes/doc.go b/tools/jsontypes/doc.go index 1725dc3..806231d 100644 --- a/tools/jsontypes/doc.go +++ b/tools/jsontypes/doc.go @@ -35,12 +35,16 @@ // dec.UseNumber() // dec.Decode(&data) // -// a, _ := jsontypes.NewAnalyzer(false, true, false) // anonymous mode -// defer a.Close() -// +// a := jsontypes.New(jsontypes.AnalyzerConfig{}) // paths := jsontypes.FormatPaths(a.Analyze(".", data)) // fmt.Print(jsontypes.GenerateTypeScript(paths)) // +// Or use the one-shot API: +// +// out, _ := jsontypes.AutoGenerate(jsonBytes, jsontypes.Options{ +// Format: jsontypes.FormatTypeScript, +// }) +// // # AI tool use // // This package is designed to be callable as an AI skill. Given a JSON diff --git a/tools/jsontypes/format.go b/tools/jsontypes/format.go index b4f7673..b268a30 100644 --- a/tools/jsontypes/format.go +++ b/tools/jsontypes/format.go @@ -57,7 +57,7 @@ func parsePath(path string) []segment { 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) // - Each type introduction gets its own line // - Type annotations only appear on the rightmost (new) segment of each line diff --git a/tools/jsontypes/format_test.go b/tools/jsontypes/format_test.go index 9d7dc5d..b81cb51 100644 --- a/tools/jsontypes/format_test.go +++ b/tools/jsontypes/format_test.go @@ -1,8 +1,6 @@ package jsontypes import ( - "bufio" - "io" "strings" "testing" ) @@ -132,18 +130,15 @@ func TestDifferentTypesEndToEnd(t *testing.T) { } obj := map[string]any{"items": arr, "count": jsonNum("4"), "status": "ok"} - a := &Analyzer{ - Prompter: &Prompter{ - reader: bufio.NewReader(strings.NewReader("")), - output: io.Discard, - // Root has 3 field-like keys → confident struct, no prompt needed. - // Then items[] has 2 shapes → unification prompt: "d" for different, - // then names for each shape. - priorAnswers: []string{"d", "FileField", "FeatureField"}, - }, - knownTypes: make(map[string]*structType), - typesByName: make(map[string]*structType), - } + a := New(AnalyzerConfig{ + // Root has 3 field-like keys → confident struct, no resolver call needed. + // Then items[] has 2 shapes → unification: different types, then names. + Resolver: scriptedResolver( + Response{IsNewType: true}, // different types for shapes + Response{Name: "FileField"}, // name for shape 1 + Response{Name: "FeatureField"}, // name for shape 2 + ), + }) rawPaths := a.Analyze(".", obj) formatted := FormatPaths(rawPaths) diff --git a/tools/jsontypes/gostruct.go b/tools/jsontypes/gostruct.go index b49e13a..eda98f1 100644 --- a/tools/jsontypes/gostruct.go +++ b/tools/jsontypes/gostruct.go @@ -49,7 +49,7 @@ func (u *goUnion) wrapperTypeName() string { 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 // a sealed interface, discriminator function, and wrapper type. func GenerateGoStructs(paths []string) string { diff --git a/tools/jsontypes/gostruct_test.go b/tools/jsontypes/gostruct_test.go index fe106a8..aa64057 100644 --- a/tools/jsontypes/gostruct_test.go +++ b/tools/jsontypes/gostruct_test.go @@ -1,10 +1,8 @@ package jsontypes import ( - "bufio" "encoding/json" "fmt" - "io" "os" "os/exec" "path/filepath" @@ -650,15 +648,13 @@ func TestGoStructUnionEndToEnd(t *testing.T) { } obj := map[string]any{"items": arr, "count": jsonNum("4"), "status": "ok"} - a := &Analyzer{ - Prompter: &Prompter{ - reader: bufio.NewReader(strings.NewReader("")), - output: io.Discard, - priorAnswers: []string{"d", "FileField", "FeatureField"}, - }, - knownTypes: make(map[string]*structType), - typesByName: make(map[string]*structType), - } + a := New(AnalyzerConfig{ + Resolver: scriptedResolver( + Response{IsNewType: true}, // different types for shapes + Response{Name: "FileField"}, // name for shape 1 + Response{Name: "FeatureField"}, // name for shape 2 + ), + }) rawPaths := a.Analyze(".", obj) formatted := FormatPaths(rawPaths) goCode := GenerateGoStructs(formatted) diff --git a/tools/jsontypes/jsdoc.go b/tools/jsontypes/jsdoc.go index 3a5b687..405886f 100644 --- a/tools/jsontypes/jsdoc.go +++ b/tools/jsontypes/jsdoc.go @@ -5,7 +5,7 @@ import ( "strings" ) -// generateJSDoc converts formatted flat paths into JSDoc @typedef annotations. +// GenerateJSDoc converts formatted flat paths into JSDoc @typedef annotations. func GenerateJSDoc(paths []string) string { types, _ := buildGoTypes(paths) if len(types) == 0 { diff --git a/tools/jsontypes/jsonschema.go b/tools/jsontypes/jsonschema.go index 60480fb..0435998 100644 --- a/tools/jsontypes/jsonschema.go +++ b/tools/jsontypes/jsonschema.go @@ -5,7 +5,7 @@ import ( "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 { types, _ := buildGoTypes(paths) diff --git a/tools/jsontypes/python.go b/tools/jsontypes/python.go index 93c38e7..bdfc537 100644 --- a/tools/jsontypes/python.go +++ b/tools/jsontypes/python.go @@ -5,7 +5,7 @@ import ( "strings" ) -// generatePython converts formatted flat paths into Python TypedDict definitions. +// GeneratePython converts formatted flat paths into Python TypedDict definitions. func GeneratePython(paths []string) string { types, _ := buildGoTypes(paths) if len(types) == 0 { diff --git a/tools/jsontypes/resolver.go b/tools/jsontypes/resolver.go new file mode 100644 index 0000000..6935497 --- /dev/null +++ b/tools/jsontypes/resolver.go @@ -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 +} diff --git a/tools/jsontypes/sql.go b/tools/jsontypes/sql.go index b35acb4..63a3d25 100644 --- a/tools/jsontypes/sql.go +++ b/tools/jsontypes/sql.go @@ -5,7 +5,7 @@ import ( "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. // Arrays of structs get a join table or FK pointing back to the parent. func GenerateSQL(paths []string) string { diff --git a/tools/jsontypes/typedef.go b/tools/jsontypes/typedef.go index 7c1c796..6910b27 100644 --- a/tools/jsontypes/typedef.go +++ b/tools/jsontypes/typedef.go @@ -5,7 +5,7 @@ import ( "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 { types, _ := buildGoTypes(paths) @@ -92,6 +92,7 @@ func goTypeToJTDInner(goTyp string, typeMap map[string]goType, defs map[string]a case "string": return map[string]any{"type": "string"} case "int64": + // JTD (RFC 8927) has no int64 type; int32 is the largest integer type available. return map[string]any{"type": "int32"} case "float64": return map[string]any{"type": "float64"} diff --git a/tools/jsontypes/typedef_test.go b/tools/jsontypes/typedef_test.go index b8faedc..4330120 100644 --- a/tools/jsontypes/typedef_test.go +++ b/tools/jsontypes/typedef_test.go @@ -140,11 +140,7 @@ func analyzeAndFormat(t *testing.T, jsonStr string) []string { if err := dec.Decode(&data); err != nil { t.Fatalf("invalid test JSON: %v", err) } - a, err := NewAnalyzer(false, true, false) - if err != nil { - t.Fatalf("NewAnalyzer: %v", err) - } - defer a.Close() + a := New(AnalyzerConfig{}) rawPaths := a.Analyze(".", data) return FormatPaths(rawPaths) } diff --git a/tools/jsontypes/typescript.go b/tools/jsontypes/typescript.go index d32a647..8667044 100644 --- a/tools/jsontypes/typescript.go +++ b/tools/jsontypes/typescript.go @@ -5,7 +5,7 @@ import ( "strings" ) -// generateTypeScript converts formatted flat paths into TypeScript interface definitions. +// GenerateTypeScript converts formatted flat paths into TypeScript interface definitions. func GenerateTypeScript(paths []string) string { types, _ := buildGoTypes(paths) if len(types) == 0 { diff --git a/tools/jsontypes/zod.go b/tools/jsontypes/zod.go index 7b14845..ec176ad 100644 --- a/tools/jsontypes/zod.go +++ b/tools/jsontypes/zod.go @@ -5,7 +5,7 @@ import ( "strings" ) -// generateZod converts formatted flat paths into Zod schema definitions. +// GenerateZod converts formatted flat paths into Zod schema definitions. func GenerateZod(paths []string) string { types, _ := buildGoTypes(paths) if len(types) == 0 {