mirror of
https://github.com/therootcompany/golib.git
synced 2026-03-13 20:37:59 +00:00
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
235 lines
6.1 KiB
Go
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
|
|
}
|