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"
|
||||
)
|
||||
|
||||
// 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
|
||||
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
|
||||
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, ", "))
|
||||
}
|
||||
}
|
||||
|
||||
if choice == "s" {
|
||||
// Same type — analyze with all instances for field unification
|
||||
// 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 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
|
||||
}
|
||||
|
||||
@ -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{})
|
||||
}
|
||||
return a
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
|
||||
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
|
||||
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)
|
||||
|
||||
@ -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)
|
||||
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 a.Close()
|
||||
|
||||
// Load prior answers if available
|
||||
if baseName != "" && !*anonymous {
|
||||
a.Prompter.LoadAnswers(baseName + ".answers")
|
||||
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) {
|
||||
if !noCache {
|
||||
path := slugify(rawURL)
|
||||
|
||||
if !noCache {
|
||||
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") ||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
} 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
|
||||
}
|
||||
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.
|
||||
// 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 {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
// 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 {
|
||||
|
||||
@ -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"}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user