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", "
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) + } + }) + } +}