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

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

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

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

235 lines
6.1 KiB
Go

package jsontypes
import (
"bufio"
"fmt"
"io"
"os"
"strings"
)
type Prompter struct {
reader *bufio.Reader
output io.Writer
tty *os.File // non-nil if we opened /dev/tty
// Answer replay/recording
priorAnswers []string // loaded from .answers file
priorIdx int // next prior answer to use
answers []string // all answers this session (for saving)
}
// 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}
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)
}
return p, nil
}
// loadAnswers reads prior answers from a file to use as defaults.
func (p *Prompter) LoadAnswers(path string) {
data, err := os.ReadFile(path)
if err != nil {
return
}
lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n")
// Filter out empty trailing lines
for len(lines) > 0 && lines[len(lines)-1] == "" {
lines = lines[:len(lines)-1]
}
if len(lines) > 0 {
fmt.Fprintf(p.output, "using prior answers from %s\n", path)
p.priorAnswers = lines
}
}
// saveAnswers writes this session's answers to a file.
func (p *Prompter) SaveAnswers(path string) error {
if len(p.answers) == 0 {
return nil
}
return os.WriteFile(path, []byte(strings.Join(p.answers, "\n")+"\n"), 0o600)
}
// nextPrior returns the next prior answer if available, or empty string.
func (p *Prompter) nextPrior() string {
if p.priorIdx < len(p.priorAnswers) {
answer := p.priorAnswers[p.priorIdx]
p.priorIdx++
return answer
}
return ""
}
// record saves an answer for later writing.
func (p *Prompter) record(answer string) {
p.answers = append(p.answers, answer)
}
func (p *Prompter) Close() {
if p.tty != nil {
p.tty.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 {
// Override default with prior answer if available
if prior := p.nextPrior(); prior != "" {
for _, o := range options {
if prior == o {
defaultOpt = prior
break
}
}
}
hint := make([]string, len(options))
for i, o := range options {
if o == defaultOpt {
hint[i] = strings.ToUpper(o)
} else {
hint[i] = o
}
}
for {
fmt.Fprintf(p.output, "%s [%s] ", prompt, strings.Join(hint, "/"))
line, err := p.reader.ReadString('\n')
if err != nil {
p.record(defaultOpt)
return defaultOpt
}
line = strings.TrimSpace(strings.ToLower(line))
if line == "" {
p.record(defaultOpt)
return defaultOpt
}
for _, o := range options {
if line == o {
p.record(o)
return o
}
}
fmt.Fprintf(p.output, " Please enter one of: %s\n", strings.Join(options, ", "))
}
}
// 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 {
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"
if defaultVal == "m" {
hint = "m"
}
for {
fmt.Fprintf(p.output, "%s [%s] ", prompt, hint)
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
}
if line == "m" || line == "map" {
p.record("m")
return "m"
}
if len(line) > 0 && line[0] >= 'A' && line[0] <= 'Z' {
p.record(line)
return line
}
fmt.Fprintf(p.output, " Enter a TypeName (starting with uppercase), or 'm' for map\n")
}
}
// 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 {
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 {
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
}
if len(line) > 0 && line[0] >= 'A' && line[0] <= 'Z' {
p.record(line)
return line
}
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
}