From 02f6bcdd6e23f3fe951e012086a7d9e31f09bb88 Mon Sep 17 00:00:00 2001 From: "Chris (ChrisJr404)" <11917633+ChrisJr404@users.noreply.github.com> Date: Tue, 25 Aug 2026 19:26:45 -0400 Subject: [PATCH] add auto input type for content based format detection Reading from stdin still needs -i today, which is awkward when you don't know the format up front (the classic case being kubectl -o yaml | qq). This adds an opt-in "auto" input type that sniffs the content and picks json, yaml, toml or xml/html. Detection stays conservative: it only commits to a format when a strict parse confirms it, and errors out otherwise so we never silently guess wrong. Default behaviour is unchanged since it only runs when you pass -i auto. Closes #34 --- README.md | 5 ++- cli/qq.go | 13 ++++++-- codec/detect.go | 77 ++++++++++++++++++++++++++++++++++++++++++++ codec/detect_test.go | 56 ++++++++++++++++++++++++++++++++ 4 files changed, 148 insertions(+), 3 deletions(-) create mode 100644 codec/detect.go create mode 100644 codec/detect_test.go diff --git a/README.md b/README.md index 822f7f5..a646e13 100644 --- a/README.md +++ b/README.md @@ -15,12 +15,15 @@ Read-only: `.proto`, `.env` ## Transcoding Between Formats -Any input format can be transcoded to any output format in a single command. Use `-i`/`--input` and `-o`/`--output` to specify formats explicitly, or let `qq` detect them from the file extension. +Any input format can be transcoded to any output format in a single command. Use `-i`/`--input` and `-o`/`--output` to specify formats explicitly, or let `qq` detect them from the file extension. When reading from stdin and you don't know the format up front, pass `-i auto` to detect it from the content (json, yaml, toml and xml/html). ```sh # YAML → JSON qq '.' config.yaml -o json +# Detect the input format from content (handy for stdin) +kubectl get pod my-pod -o yaml | qq -i auto '.metadata.name' + # TOML → YAML qq '.' pyproject.toml -o yaml diff --git a/cli/qq.go b/cli/qq.go index cf34534..0a0c49a 100644 --- a/cli/qq.go +++ b/cli/qq.go @@ -49,7 +49,7 @@ func CreateRootCmd() *cobra.Command { handleCommand(cmd, args, inputType, outputType, rawOutput, help, interactive, monochrome, stream, slurp, exitStatus) }, } - cmd.Flags().StringVarP(&inputType, "input", "i", "json", "specify input file type, only required on parsing stdin.") + cmd.Flags().StringVarP(&inputType, "input", "i", "json", "specify input file type, only required on parsing stdin. use \"auto\" to detect the format from the content.") cmd.Flags().StringVarP(&outputType, "output", "o", "json", "specify output file type by extension name. This is inferred from extension if passing file position argument.") cmd.Flags().BoolVarP(&rawOutput, "raw-output", "r", false, "output strings without escapes and quotes.") cmd.Flags().BoolVarP(&help, "help", "h", false, "help for qq") @@ -161,7 +161,16 @@ func handleCommand(cmd *cobra.Command, args []string, inputtype string, outputty // Check if -i flag was explicitly set by user inputFlagSet := cmd.Flags().Changed("input") - if inputFlagSet { + if strings.EqualFold(inputtype, "auto") { + // Content based detection, opt-in via -i auto so it never changes the + // default behaviour. Detection needs the whole input buffered, which is + // at odds with streaming, so ask the user to be explicit there. + if stream { + fmt.Println("Error: input type \"auto\" cannot be used with --stream; specify the input type explicitly") + os.Exit(1) + } + inputCodec, err = codec.Detect(input) + } else if inputFlagSet { // -i flag takes precedence over file extension inputCodec, err = codec.GetEncodingType(inputtype) } else if filename != "" { diff --git a/codec/detect.go b/codec/detect.go new file mode 100644 index 0000000..124db82 --- /dev/null +++ b/codec/detect.go @@ -0,0 +1,77 @@ +package codec + +import ( + "bytes" + "fmt" + "reflect" + + "github.com/goccy/go-json" +) + +// Detect infers the encoding of input from its content. It backs the "auto" +// input type and is intentionally conservative: it only reports a format when a +// strict parse confirms it, and returns an error otherwise so callers can ask +// the user to be explicit rather than guess wrong. +// +// Detection covers the common structured text formats (json, xml/html, toml, +// yaml). Binary formats and ambiguous line based formats (csv, ini, env, +// properties, ...) are deliberately left out because their inputs overlap too +// much to tell apart reliably. +func Detect(input []byte) (EncodingType, error) { + trimmed := bytes.TrimSpace(input) + if len(trimmed) == 0 { + return JSON, fmt.Errorf("cannot detect input format: empty input") + } + + switch trimmed[0] { + case '{', '[': + if json.Valid(input) { + return JSON, nil + } + case '<': + lower := bytes.ToLower(trimmed) + if bytes.HasPrefix(lower, []byte("1", XML}, + {"xml with declaration", "x", XML}, + {"html doctype", "

hi

", HTML}, + {"html tag", "", HTML}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Detect([]byte(tt.input)) + if err != nil { + t.Fatalf("Detect(%q) returned error: %v", tt.input, err) + } + if got != tt.expected { + t.Errorf("Detect(%q) = %v, want %v", tt.input, got, tt.expected) + } + }) + } +} + +func TestDetectUndetectable(t *testing.T) { + // Inputs that Detect should refuse rather than guess at. + cases := []struct { + name string + input string + }{ + {"empty", ""}, + {"whitespace only", " \n\t"}, + {"plain scalar", "just some words"}, + {"bare number", "42"}, + } + + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + if got, err := Detect([]byte(tt.input)); err == nil { + t.Errorf("Detect(%q) = %v, expected an error", tt.input, got) + } + }) + } +}