From 34b5d5fb47f0a2245f597296ca576b1ebc62ea92 Mon Sep 17 00:00:00 2001 From: Vasu Nagendra Date: Sun, 5 Jul 2026 06:21:02 -0500 Subject: [PATCH 1/4] Break binding pipes and if/elif/else Added break for the pipe operator when the lhs is not just a value ex: `.a as X |`, and added breaks for `if/elif/else` operator. Co-Authored-By: Claude Opus 4.8 --- cmd/jqfmt/main.go | 2 ++ jqfmt.go | 1 + jqfmt_test.go | 40 +++++++++++++++++++++++++++++++++ lib.go | 42 ++++++++++++++++++++++++++++++----- testdata/if-in.jq | 1 + testdata/if-out.jq | 4 ++++ testdata/operator-pipe-in.jq | 2 +- testdata/operator-pipe-out.jq | 5 +++-- 8 files changed, 88 insertions(+), 9 deletions(-) create mode 100644 testdata/if-in.jq create mode 100644 testdata/if-out.jq diff --git a/cmd/jqfmt/main.go b/cmd/jqfmt/main.go index 6f8b916..764ae7b 100644 --- a/cmd/jqfmt/main.go +++ b/cmd/jqfmt/main.go @@ -22,6 +22,7 @@ func main() { opsStr := flag.String("op", "", "operators") obj := flag.Bool("ob", false, "objects") arr := flag.Bool("ar", false, "arrays") + ifBrk := flag.Bool("if", false, "break if/elif/else/end") oneLn := flag.Bool("o", false, "one line") file := flag.String("f", "", "file") verbose := flag.Bool("v", false, "verbose") @@ -55,6 +56,7 @@ func main() { Arr: *arr, // Funcs: funcs, Obj: *obj, + If: *ifBrk, OneLn: *oneLn, Ops: ops, // Hang: !(*noHang), diff --git a/jqfmt.go b/jqfmt.go index f46c8c5..c044f74 100644 --- a/jqfmt.go +++ b/jqfmt.go @@ -17,6 +17,7 @@ type JqFmtCfg struct { Ops []string Obj bool Arr bool + If bool Hang bool OneLn bool diff --git a/jqfmt_test.go b/jqfmt_test.go index 1f80d92..d898e8f 100644 --- a/jqfmt_test.go +++ b/jqfmt_test.go @@ -412,3 +412,43 @@ func TestFunction(t *testing.T) { } } */ + +// TestIf covers breaking a conditional: each of elif/else/end starts its own +// line, aligned with the "if". +func TestIf(t *testing.T) { + cases := []struct { + inFile string + outFile string + }{ + {"testdata/if-in.jq", "testdata/if-out.jq"}, + } + + cfg = JqFmtCfg{ + If: true, + } + + for _, c := range cases { + inBytes, err := ioutil.ReadFile(c.inFile) + if err != nil { + t.Fatalf("failed to open input file: %s", err) + } + in := string(inBytes) + + wantBytes, err := ioutil.ReadFile(c.outFile) + if err != nil { + t.Fatalf("failed to open want file: %s", err) + } + want := string(wantBytes) + + out, err := DoThing(in, cfg) + if err != nil { + t.Fatalf("could not do thing: %s", err) + } + + if !reflect.DeepEqual(want, out) { + t.Logf("want: %s", want) + t.Logf("have: %s", out) + t.Errorf("%s does not match %s", c.inFile, c.outFile) + } + } +} diff --git a/lib.go b/lib.go index 95b5a01..e546fe2 100644 --- a/lib.go +++ b/lib.go @@ -11,6 +11,7 @@ import ( "math" "math/big" // "regexp" + "slices" "sort" "strconv" "strings" @@ -1096,7 +1097,15 @@ func (e *Bind) writeTo(s *strings.Builder) { s.WriteByte(' ') } } - s.WriteString("| ") + // A binding's pipe wraps like any other pipe when pipe breaking is on: just + // break the line. The body inherits its indent from the enclosing context, + // the same way a comma-broken array element does. + if slices.Contains(cfg.Ops, "pipe") { + s.WriteString("|") + brk(s) + } else { + s.WriteString("| ") + } e.Body.writeTo(s) } @@ -1119,19 +1128,40 @@ func (e *If) String() string { } func (e *If) writeTo(s *strings.Builder) { + // When breaking, elif/else/end each start their own line at the if's own + // indent — a closer lines up with its opener, like fi/end/done elsewhere. + // prtIdt writes that indent the same way it does for every other line. + ifBrk := func() { + brk(s) + prtIdt(s) + } s.WriteString("if ") e.Cond.writeTo(s) s.WriteString(" then ") e.Then.writeTo(s) - for _, e := range e.Elif { - s.WriteByte(' ') - e.writeTo(s) + for _, elif := range e.Elif { + if cfg.If { + ifBrk() + } else { + s.WriteByte(' ') + } + elif.writeTo(s) } if e.Else != nil { - s.WriteString(" else ") + if cfg.If { + ifBrk() + s.WriteString("else ") + } else { + s.WriteString(" else ") + } e.Else.writeTo(s) } - s.WriteString(" end") + if cfg.If { + ifBrk() + s.WriteString("end") + } else { + s.WriteString(" end") + } } func (e *If) minify() { diff --git a/testdata/if-in.jq b/testdata/if-in.jq new file mode 100644 index 0000000..0a54be0 --- /dev/null +++ b/testdata/if-in.jq @@ -0,0 +1 @@ +if .a then .b elif .c then .d else .e end \ No newline at end of file diff --git a/testdata/if-out.jq b/testdata/if-out.jq new file mode 100644 index 0000000..b5300ad --- /dev/null +++ b/testdata/if-out.jq @@ -0,0 +1,4 @@ +if .a then .b +elif .c then .d +else .e +end \ No newline at end of file diff --git a/testdata/operator-pipe-in.jq b/testdata/operator-pipe-in.jq index 034b43c..38a3f58 100644 --- a/testdata/operator-pipe-in.jq +++ b/testdata/operator-pipe-in.jq @@ -1 +1 @@ -this | that | other \ No newline at end of file +this | that as $x | other as $y | more \ No newline at end of file diff --git a/testdata/operator-pipe-out.jq b/testdata/operator-pipe-out.jq index 8ad7364..0d59ea1 100644 --- a/testdata/operator-pipe-out.jq +++ b/testdata/operator-pipe-out.jq @@ -1,3 +1,4 @@ this | - that | - other \ No newline at end of file + that as $x | + other as $y | + more \ No newline at end of file From 704e943aa0619914a106757dca28fca82962b903 Mon Sep 17 00:00:00 2001 From: Vasu Nagendra Date: Sun, 5 Jul 2026 13:56:15 -0500 Subject: [PATCH 2/4] Reimplement the original idea with a simplified version. This version takes inspiration from Rob Pike's talk around writing a scanner in go. That's what was happening before as well. There wasn't anything wrong with the original idea. I just reimplemented it because it seemed simpler to think of the problem this way rather than the way it was written. Code reduction/increase is a consequence of reframing the problem, not the goal. Co-Authored-By: Claude Opus 4.8 (1M context) --- cmd/jqfmt/main.go | 96 ++- export.go | 28 - format.go | 295 ++++++++ go.mod | 10 +- go.sum | 17 - jqfmt.go | 315 -------- jqfmt_test.go | 495 ++----------- lib.go | 1733 --------------------------------------------- scanner.go | 277 ++++++++ 9 files changed, 693 insertions(+), 2573 deletions(-) delete mode 100644 export.go create mode 100644 format.go delete mode 100644 jqfmt.go delete mode 100644 lib.go create mode 100644 scanner.go diff --git a/cmd/jqfmt/main.go b/cmd/jqfmt/main.go index 764ae7b..ff2ddb7 100644 --- a/cmd/jqfmt/main.go +++ b/cmd/jqfmt/main.go @@ -1,3 +1,5 @@ +// Command jqfmt formats a jq program. It reads from a file (-f) or stdin and +// writes the formatted result to stdout. package main import ( @@ -8,70 +10,56 @@ import ( "strings" "github.com/noperator/jqfmt" - log "github.com/sirupsen/logrus" ) -func assertErrorToNilf(message string, err error) { - if err != nil { - log.Fatalf(message, err) - } -} - func main() { - // funcsStr := flag.String("fn", "", "functions") - opsStr := flag.String("op", "", "operators") - obj := flag.Bool("ob", false, "objects") - arr := flag.Bool("ar", false, "arrays") - ifBrk := flag.Bool("if", false, "break if/elif/else/end") - oneLn := flag.Bool("o", false, "one line") - file := flag.String("f", "", "file") - verbose := flag.Bool("v", false, "verbose") - // noHang := flag.Bool("nh", false, "no hanging indent") + ops := flag.String("op", "", "comma-separated operators to break on (pipe,comma,add,...)") + funcs := flag.String("fn", "", "comma-separated function names to break the line before") + obj := flag.Bool("ob", false, "break object literals, one key per line") + arr := flag.Bool("ar", false, "break array literals, one element per line") + ifBrk := flag.Bool("if", false, "break if/elif/else/end onto their own lines") + oneLn := flag.Bool("o", false, "collapse to a single canonical line") + file := flag.String("f", "", "read jq from this file instead of stdin") flag.Parse() - var from_stdin bool = false - if *verbose { - log.SetLevel(log.DebugLevel) - } + cfg, err := jqfmt.ValidateConfig(jqfmt.Config{ + Ops: splitList(*ops), + Funcs: splitList(*funcs), + Obj: *obj, + Arr: *arr, + If: *ifBrk, + OneLn: *oneLn, + }) + fail("invalid config", err) - if *file == "" { - from_stdin = true - } + src, err := read(*file) + fail("could not read input", err) - // var funcs []string - // if *funcsStr == "" { - // funcs = []string{} - // } else { - // funcs = strings.Split(*funcsStr, ",") - // } + out, err := jqfmt.Format(src, cfg) + fail("could not format jq", err) - var ops []string - if *opsStr == "" { - ops = []string{} - } else { - ops = strings.Split(*opsStr, ",") + fmt.Print(out) +} + +func splitList(s string) []string { + if s == "" { + return nil } + return strings.Split(s, ",") +} - cliCfg, err := jqfmt.ValidateConfig(jqfmt.JqFmtCfg{ - Arr: *arr, - // Funcs: funcs, - Obj: *obj, - If: *ifBrk, - OneLn: *oneLn, - Ops: ops, - // Hang: !(*noHang), - }) - assertErrorToNilf("invalid config: %v", err) +func read(file string) (string, error) { + if file == "" { + b, err := io.ReadAll(os.Stdin) + return string(b), err + } + b, err := os.ReadFile(file) + return string(b), err +} - var jqBytes []byte - if from_stdin { - jqBytes, err = io.ReadAll(os.Stdin) - } else { - jqBytes, err = os.ReadFile(*file) +func fail(msg string, err error) { + if err != nil { + fmt.Fprintf(os.Stderr, "%s: %v\n", msg, err) + os.Exit(1) } - assertErrorToNilf("could not read file: %v", err) - jqStr := string(jqBytes) - jqStrFmt, err := jqfmt.DoThing(jqStr, cliCfg) - assertErrorToNilf("could not format jq: %v", err) - fmt.Print(jqStrFmt) } diff --git a/export.go b/export.go deleted file mode 100644 index 682e28c..0000000 --- a/export.go +++ /dev/null @@ -1,28 +0,0 @@ -package jqfmt - -import ( - "encoding/json" - "fmt" -) - -// var AssertErrorToNilf = assertErrorToNilf -// var GetCmdStdDir = getCmdStdDir -// var Indent = indent -// var ModProg = modProg -// var ParseProg = parseProg -// var TreeToStr = treeToStr -// var FmtProg = fmtProg -// var FmtJq = fmtJq -// var FmtSh = fmtSh -// var ImplodeSh = implodeSh -// var ExplodeSh = explodeSh - -var Cfg = cfg - -// var Cmds = cmds - -// TODO: Move this to util or something. -func PrintJSON(obj interface{}) { - bytes, _ := json.MarshalIndent(obj, "\t", "\t") - fmt.Println(string(bytes)) -} diff --git a/format.go b/format.go new file mode 100644 index 0000000..29913ee --- /dev/null +++ b/format.go @@ -0,0 +1,295 @@ +package jqfmt + +import ( + "fmt" + "slices" + "strings" + + "github.com/itchyny/gojq" +) + +// Config says which constructs get broken across lines. +type Config struct { + Ops []string // operator names to break on: pipe, comma, add, assign, ... + Funcs []string // function names to break the line before a call to + Obj bool // break object literals, one key per line + Arr bool // break array literals, one element per line + If bool // break if/elif/else/end onto their own lines + Hang bool // reserved; kept so the CLI flag set still compiles + OneLn bool // collapse to a single canonical line +} + +// cfg exists so the existing test file can assign to it as a package global. +// Format takes its config by argument; this is just here for that compatibility. +var cfg Config + +// operatorTable is the one place we spell out jq's binary and update operators: +// the literal symbol and the name used in Config.Ops. The scanner walks it to +// match operators (longest symbol first, so // beats / and //= beats //), and +// the formatter looks up a symbol's config name here. Comma rides along because +// it lexes like an operator, though the formatter treats it specially. +var operatorTable = []struct { + symbol string + name string +}{ + {"//=", "updateAlt"}, + {"==", "eq"}, + {"!=", "ne"}, + {"<=", "le"}, + {">=", "ge"}, + {"//", "alt"}, + {"|=", "modify"}, + {"+=", "updateAdd"}, + {"-=", "updateSub"}, + {"*=", "updateMul"}, + {"/=", "updateDiv"}, + {"%=", "updateMod"}, + {"|", "pipe"}, + {",", "comma"}, + {"+", "add"}, + {"-", "sub"}, + {"*", "mul"}, + {"/", "div"}, + {"%", "mod"}, + {"=", "assign"}, + {"<", "lt"}, + {">", "gt"}, +} + +// wordOperators are operators spelled as words. The scanner lexes them as plain +// identifiers, so they live apart from operatorTable, but they're valid Config +// names and break like any other operator. +var wordOperators = []string{"and", "or"} + +// opName returns the Config name for an operator symbol, or "" if unknown. +func opName(symbol string) string { + for _, op := range operatorTable { + if op.symbol == symbol { + return op.name + } + } + return "" +} + +// Format parses jq with gojq, canonicalizes it (gojq's String() gives us one +// predictable starting form), then inserts line breaks per the config. gojq +// owns parsing; the only thing we hand-roll is the scan-and-break pass. +func Format(jqStr string, c Config) (string, error) { + cfg = c + query, err := gojq.Parse(jqStr) + if err != nil { + return "", fmt.Errorf("could not parse jq: %w", err) + } + canonical := query.String() + if c.OneLn { + return canonical, nil + } + return breakLines(canonical, c), nil +} + +// frame tracks one open bracket. broken means we've split its contents across +// lines (a broken array/object), which bumps the indent for what's inside. +type frame struct { + broken bool +} + +// breakLines walks the token stream and rebuilds the source with breaks +// inserted. The rule is: replay each token's canonical leading whitespace, +// except where we decide to break — there we drop the whitespace and start a +// fresh line at the current indent instead. +func breakLines(src string, c Config) string { + var tokens []token + for t := range scan(src) { + if t.kind == tokenEOF { + break + } + tokens = append(tokens, t) + } + + var out strings.Builder + var frames []frame + indent := 0 // current base indent, in 4-space units + interp := 0 // string-interpolation depth; we never break inside one + breakNext := -1 // if >= 0, the next token starts a new line at this indent + prev := tokenEOF // previous token kind, for classifying '[' + + for i, t := range tokens { + // Where does this token go — same line, or a fresh one? + line := breakNext + breakNext = -1 + + // A broken bracket drops its closer onto its own line at the parent + // indent (the "]" or "}" lines up under the opener's line). + if isClose(t.kind) && top(frames).broken { + indent-- + line = indent + } + + // if/elif/else/end: with If on, each of elif/else/end starts its own + // line aligned with the if. Guard on prev != dot so a field named + // ".end" isn't mistaken for the keyword. + if c.If && interp == 0 && len(frames) == 0 && prev != tokenDot && + t.kind == tokenIdent && isIfCloser(t.text) { + line = indent + } + + // Break the line before a call to a named function (Config.Funcs). The + // prev guards skip the program's first token and field access like .map. + if interp == 0 && prev != tokenEOF && prev != tokenDot && + t.kind == tokenIdent && slices.Contains(c.Funcs, t.text) { + line = indent + } + + if line >= 0 { + out.WriteByte('\n') + out.WriteString(strings.Repeat(" ", line)) + } else { + out.WriteString(t.pre) + } + out.WriteString(t.text) + + // Inside a string interpolation we track nesting but never break — + // splitting a string across lines would be surprising, even where jq + // allows it. So the interpolation's tokens replay verbatim. + if t.kind == tokenInterpolationStart { + interp++ + frames = append(frames, frame{}) + prev = t.kind + continue + } + if t.kind == tokenInterpolationEnd { + interp-- + frames = pop(frames) + prev = t.kind + continue + } + if interp > 0 { + prev = t.kind + continue + } + + // Update nesting and schedule whatever break should follow this token. + switch t.kind { + case tokenLParen: + frames = append(frames, frame{}) + case tokenLBracket: + broken := c.Arr && !isIndexBracket(prev) && !emptyPair(tokens, i, tokenRBracket) + frames = append(frames, frame{broken: broken}) + if broken { + indent++ + breakNext = indent + } + case tokenLBrace: + broken := c.Obj && !emptyPair(tokens, i, tokenRBrace) + frames = append(frames, frame{broken: broken}) + if broken { + indent++ + breakNext = indent + } + case tokenRParen, tokenRBracket, tokenRBrace: + frames = pop(frames) + case tokenOp: + breakNext = opBreak(t.text, frames, indent, c) + case tokenIdent: + if slices.Contains(wordOperators, t.text) && slices.Contains(c.Ops, t.text) { + breakNext = indent + spine(frames) + } + } + + prev = t.kind + } + return out.String() +} + +// opBreak reports the indent for the line after a broken operator, or -1 to +// keep the next token on the same line. Comma is special: inside a broken +// array/object it's an element separator (always breaks); otherwise it's the +// comma operator and breaks only when "comma" is configured. +func opBreak(op string, frames []frame, indent int, c Config) int { + if op == "," { + if top(frames).broken { + return indent + } + if slices.Contains(c.Ops, "comma") { + return indent + spine(frames) + } + return -1 + } + if name := opName(op); name != "" && slices.Contains(c.Ops, name) { + return indent + spine(frames) + } + return -1 +} + +// spine adds one indent level for a top-level operator break and none for a +// nested one. That's the quirk in the fixtures: pipes buried inside a function +// call's arguments break but stay flush left, while the top-level pipe indents +// its right-hand side. +func spine(frames []frame) int { + if len(frames) == 0 { + return 1 + } + return 0 +} + +// isIndexBracket reports whether a '[' following prev is an index/iterator +// (like flatten[] or .[0]) rather than an array literal. It's an index when it +// comes right after something that produces a value. +func isIndexBracket(prev tokenKind) bool { + switch prev { + case tokenIdent, tokenNumber, tokenString, tokenVar, + tokenRParen, tokenRBracket, tokenInterpolationEnd, tokenDot, tokenQuestion: + return true + } + return false +} + +func isClose(k tokenKind) bool { return k == tokenRBracket || k == tokenRBrace } + +func isIfCloser(text string) bool { + return text == "elif" || text == "else" || text == "end" +} + +// emptyPair reports whether the bracket opened at i is immediately closed, e.g. +// "{}" or "[]". We don't break an empty pair onto three lines. +func emptyPair(tokens []token, i int, closer tokenKind) bool { + return i+1 < len(tokens) && tokens[i+1].kind == closer +} + +func top(frames []frame) frame { + if len(frames) == 0 { + return frame{} + } + return frames[len(frames)-1] +} + +func pop(frames []frame) []frame { + if len(frames) == 0 { + return frames + } + return frames[:len(frames)-1] +} + +// ValidateConfig normalizes and checks the operator names, same contract as the +// original package so the CLI keeps working. +func ValidateConfig(c Config) (Config, error) { + // Valid Config.Ops names: every operator symbol's name, plus the word ones. + valid := append([]string{}, wordOperators...) + for _, op := range operatorTable { + valid = append(valid, op.name) + } + for i, op := range c.Ops { + matched := false + for _, v := range valid { + if strings.EqualFold(op, v) { + c.Ops[i] = v + matched = true + } + } + if !matched { + return c, fmt.Errorf("invalid operator %q; valid operators: %s", + op, strings.Join(valid, ", ")) + } + } + return c, nil +} diff --git a/go.mod b/go.mod index d4efb26..82d53de 100644 --- a/go.mod +++ b/go.mod @@ -2,12 +2,6 @@ module github.com/noperator/jqfmt go 1.21.5 -require ( - github.com/itchyny/gojq v0.12.14 - github.com/sirupsen/logrus v1.9.3 -) +require github.com/itchyny/gojq v0.12.14 -require ( - github.com/itchyny/timefmt-go v0.1.5 // indirect - golang.org/x/sys v0.15.0 // indirect -) +require github.com/itchyny/timefmt-go v0.1.5 // indirect diff --git a/go.sum b/go.sum index b0a6032..e790798 100644 --- a/go.sum +++ b/go.sum @@ -1,21 +1,4 @@ -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/itchyny/gojq v0.12.14 h1:6k8vVtsrhQSYgSGg827AD+PVVaB1NLXEdX+dda2oZCc= github.com/itchyny/gojq v0.12.14/go.mod h1:y1G7oO7XkcR1LPZO59KyoCRy08T3j9vDYRV0GgYSS+s= github.com/itchyny/timefmt-go v0.1.5 h1:G0INE2la8S6ru/ZI5JecgyzbbJNs5lG1RcBqa7Jm6GE= github.com/itchyny/timefmt-go v0.1.5/go.mod h1:nEP7L+2YmAbT2kZ2HfSs1d8Xtw9LY8D2stDBckWakZ8= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= -github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= -github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= -golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/jqfmt.go b/jqfmt.go deleted file mode 100644 index c044f74..0000000 --- a/jqfmt.go +++ /dev/null @@ -1,315 +0,0 @@ -package jqfmt - -import ( - "encoding/json" - "fmt" - "io/ioutil" - "os/user" - "path/filepath" - "regexp" - "strings" - - "github.com/itchyny/gojq" -) - -type JqFmtCfg struct { - // Funcs []string - Ops []string - Obj bool - Arr bool - If bool - - Hang bool - OneLn bool -} - -var cfg JqFmtCfg - -var line int -var node string -var ancestor string -var idt int -var nodeIdts map[string][]string -var queries map[string]int -var indented map[int]int - -// var funcs []string -// var funcDefs map[string]string -// var modFuncs []string -// var modFuncDefs map[string]string -// var progFuncs []string -// var progFuncDefs map[string]string -var lastIdt int - -func ValidateConfig(cfg JqFmtCfg) (JqFmtCfg, error) { - validOps := []string{ - "pipe", - "comma", - "add", - "sub", - "mul", - "div", - "mod", - "eq", - "ne", - "gt", - "lt", - "ge", - "le", - "and", - "or", - "alt", - "assign", - "modify", - "updateAdd", - "updateSub", - "updateMul", - "updateDiv", - "updateMod", - "updateAlt", - } - - ops := cfg.Ops - for o, op := range ops { - valid := false - for _, vop := range validOps { - if strings.ToLower(op) == strings.ToLower(vop) { - ops[o] = vop - valid = true - } - } - if !valid { - return cfg, fmt.Errorf("invalid operator \"%s\"; valid operators: %s\n", op, strings.Join(validOps[:], ", ")) - } - } - cfg.Ops = ops - - return cfg, nil - -} - -func strToQuery(jqStr string) (Query, error) { - - jqAstQ := Query{} - - // Parse into AST. - jqAst, err := gojq.Parse(jqStr) - // TODO: print gojq pretty errors - if err != nil { - return jqAstQ, fmt.Errorf("could not parse jq: %w", err) - } - - // Initially format jq to give us something consistent to start with. - jqAstPty, err := gojq.Parse(jqAst.String()) - if err != nil { - return jqAstQ, fmt.Errorf("could not parse jq: %w", err) - } - - // Convert from gojq.Query to Query. - jqAstJson, err := json.Marshal(jqAstPty) - if err != nil { - return jqAstQ, fmt.Errorf("could not convert query: %w", err) - } - json.Unmarshal([]byte(jqAstJson), &jqAstQ) - - return jqAstQ, nil -} - -func DoThing(jqStr string, cfg_ JqFmtCfg) (string, error) { - - cfg = cfg_ - - // if !cfg.Hang { - // idt = 0 - // } else { - // idt = 1 - // } - idt = 0 - lastIdt = 0 - line = 1 - node = "" - ancestor = "" - nodeIdts = map[string][]string{} - // funcs = []string{} - // funcDefs = map[string]string{} - queries = map[string]int{} - indented = map[int]int{} - - /* - // Read in ~/.jq. - usr, err := user.Current() - if err != nil { - return "", fmt.Errorf("could not get user: %w", err) - } - dir := usr.HomeDir - file := filepath.Join(dir, ".jq") - modJqBytes, err := ioutil.ReadFile(file) - if err != nil { - if os.IsNotExist(err) { - // If the file doesn't exist, use an empty string instead of returning an error - modJqStr := "" - modQ, err := strToQuery(modJqStr) - if err != nil { - return "", fmt.Errorf("could not convert jq to query: %w", err) - } - _ = modQ.String() - return DoThing(jqStr, cfg_) - } - return "", fmt.Errorf("could not read file: %w", err) - } - modJqStr := string(modJqBytes) - modQ, err := strToQuery(modJqStr) - if err != nil { - return "", fmt.Errorf("could not convert jq to query: %w", err) - } - _ = modQ.String(a - // modFuncs = funcs - // modFuncDefs = funcDefs - // funcs = []string{} - // funcDefs = map[string]string{} - */ - - initQ, err := strToQuery(jqStr) - if err != nil { - return "", fmt.Errorf("could not convert jq to query: %w", err) - } - - // This'll populate funcs and funcDefs. - temp := initQ.String() - // progFuncs = funcs - // progFuncDefs = funcDefs - - /* - which funcs are used in the program? - are any of those defined only in ~/.jq (i.e., not in prog)? - prepend those - which funcDefs did we prepend? - which funcs do those use? - are any not already included? - if so, prepend those - this is some kind of loop... - */ - - // for _, pf := range progFuncs { - // inp := false - // inm := false - // for fn := range progFuncDefs { - // if fn == pf { - // inp = true - // break - // } - // } - // for fn := range modFuncDefs { - // if fn == pf { - // inm = true - // break - // } - // } - // if !inp && inm { - // temp = fmt.Sprintf("%s\n%s", modFuncDefs[pf], temp) - // } - // } - - fnlQ, err := strToQuery(temp) - if err != nil { - return "", fmt.Errorf("could not convert jq to query: %w", err) - } - - fnl := fnlQ.String() - - return fnl, nil - // fnlStr, err := indent(fnl, jqStr) - // if err != nil { - // return jqStr, fmt.Errorf("could not convert query: %w", err) - // } - // return fnlStr, nil -} - -func indent(fnl string, jqStr string) (string, error) { - - // If the smallest indent is greater than 4 spaces (the intended minimum - // indent), then bring it down to 4 by subtracting the difference. - min := -1 - for _, jqLn := range strings.Split(fnl, "\n") { - re, err := regexp.Compile("(^ +).*") - if err != nil { - return jqStr, fmt.Errorf("could not compile regex: %w", err) - } - n := re.FindStringSubmatch(jqLn) - if len(n) > 1 { - if min == -1 || len(n[1]) < min { - min = len(n[1]) - } - } - } - trunc := 0 - if min > 4 { - trunc = min - 4 - } - - first := true - out := "" - for _, jqLn := range strings.Split(fnl, "\n") { - - // Separate out indent and line. - re, err := regexp.Compile("^( *)(.*)") - if err != nil { - return jqStr, fmt.Errorf("could not compile regex: %w", err) - } - parts := re.FindStringSubmatch(jqLn) - idtPart := parts[1] - lnPart := parts[2] - - // Drop blank lines if they made their way in somehow. - if lnPart == "" { - continue - } - - // if cfg.Hang && first { - if first { - - // Leave the first line with no added indentation. - out += fmt.Sprintf("%s\n", lnPart) - first = false - } else { - - // Indent all other lines. - if len(idtPart) >= trunc { - idtPart = idtPart[trunc:] - } - out += fmt.Sprintf("%s%s\n", idtPart, lnPart) - } - } - - return out[:len(out)-1], nil - -} - -// https://stedolan.github.io/jq/manual/#Modules -func loadModules() (map[string]*gojq.FuncDef, error) { - - funcs := map[string]*gojq.FuncDef{} - - usr, err := user.Current() - if err != nil { - return nil, fmt.Errorf("could not get user: %w", err) - } - dir := usr.HomeDir - file := filepath.Join(dir, ".jq") - jqBytes, err := ioutil.ReadFile(file) - if err != nil { - return nil, fmt.Errorf("could not read file: %w", err) - } - jqStr := string(jqBytes) - - jqAst, err := gojq.Parse(jqStr) - if err != nil { - return nil, fmt.Errorf("could not parse jq: %w", err) - } - - for _, fd := range jqAst.FuncDefs { - funcs[fd.Name] = fd - } - - return funcs, nil -} diff --git a/jqfmt_test.go b/jqfmt_test.go index d898e8f..2148f6d 100644 --- a/jqfmt_test.go +++ b/jqfmt_test.go @@ -1,454 +1,113 @@ package jqfmt import ( - "fmt" - "io/ioutil" - "reflect" + "os" + "path/filepath" "strings" "testing" ) -func TestFmt(t *testing.T) { - fmt.Print("") -} - -// func TestComma(t *testing.T) { -// cases := []struct { -// inFile string -// outFile string -// }{ -// {"testdata/comma-in.jq", "testdata/comma-out.jq"}, -// } -// -// // cfg = JqFmtCfg{ -// // Arr: true, -// // } -// cfg = JqFmtCfg{ -// Ops: []string{"comma"}, -// } -// -// for _, c := range cases { -// inBytes, err := ioutil.ReadFile(c.inFile) -// if err != nil { -// t.Fatalf("failed to open input file: %s", err) -// } -// in := string(inBytes) -// -// wantBytes, err := ioutil.ReadFile(c.outFile) -// if err != nil { -// t.Fatalf("failed to open want file: %s", err) -// } -// want := string(wantBytes) -// -// out, err := DoThing(in, cfg) -// if err != nil { -// t.Fatalf("could not do thing: %s", err) -// } -// -// if !reflect.DeepEqual(want, out) { -// t.Logf("want: %s", want) -// t.Logf("have: %s", out) -// t.Errorf("%s does not match %s", c.inFile, c.outFile) -// } -// } -// } - -func TestArray(t *testing.T) { - cases := []struct { - inFile string - outFile string - }{ - {"testdata/array-in.jq", "testdata/array-out.jq"}, - } - - cfg = JqFmtCfg{ - Arr: true, - } - - for _, c := range cases { - inBytes, err := ioutil.ReadFile(c.inFile) - if err != nil { - t.Fatalf("failed to open input file: %s", err) - } - in := string(inBytes) - - wantBytes, err := ioutil.ReadFile(c.outFile) - if err != nil { - t.Fatalf("failed to open want file: %s", err) - } - want := string(wantBytes) - - out, err := DoThing(in, cfg) - if err != nil { - t.Fatalf("could not do thing: %s", err) - } - - if !reflect.DeepEqual(want, out) { - t.Logf("want: %s", want) - t.Logf("have: %s", out) - t.Errorf("%s does not match %s", c.inFile, c.outFile) - } +// checkFormat formats inFile with cfg and compares the result against outFile. +func checkFormat(t *testing.T, cfg Config, inFile, outFile string) { + t.Helper() + in, err := os.ReadFile(inFile) + if err != nil { + t.Fatalf("read input: %v", err) } -} - -func TestObject(t *testing.T) { - cases := []struct { - inFile string - outFile string - }{ - {"testdata/object-in.jq", "testdata/object-out.jq"}, + want, err := os.ReadFile(outFile) + if err != nil { + t.Fatalf("read want: %v", err) } - - cfg = JqFmtCfg{ - Obj: true, + got, err := Format(string(in), cfg) + if err != nil { + t.Fatalf("format: %v", err) } - - for _, c := range cases { - inBytes, err := ioutil.ReadFile(c.inFile) - if err != nil { - t.Fatalf("failed to open input file: %s", err) - } - in := string(inBytes) - - wantBytes, err := ioutil.ReadFile(c.outFile) - if err != nil { - t.Fatalf("failed to open want file: %s", err) - } - want := string(wantBytes) - - out, err := DoThing(in, cfg) - if err != nil { - t.Fatalf("could not do thing: %s", err) - } - - if !reflect.DeepEqual(want, out) { - t.Logf("want: %s", want) - t.Logf("have: %s", out) - t.Errorf("%s does not match %s", c.inFile, c.outFile) - } + if string(want) != got { + t.Errorf("%s:\n want: %q\n got: %q", inFile, want, got) } } -func TestOperator(t *testing.T) { - cases := []struct { - inFile string - outFile string - }{ - {"testdata/operator-pipe-in.jq", "testdata/operator-pipe-out.jq"}, - {"testdata/operator-comma-in.jq", "testdata/operator-comma-out.jq"}, - {"testdata/operator-add-in.jq", "testdata/operator-add-out.jq"}, - {"testdata/operator-sub-in.jq", "testdata/operator-sub-out.jq"}, - {"testdata/operator-mul-in.jq", "testdata/operator-mul-out.jq"}, - {"testdata/operator-div-in.jq", "testdata/operator-div-out.jq"}, - {"testdata/operator-mod-in.jq", "testdata/operator-mod-out.jq"}, - {"testdata/operator-eq-in.jq", "testdata/operator-eq-out.jq"}, - {"testdata/operator-ne-in.jq", "testdata/operator-ne-out.jq"}, - {"testdata/operator-gt-in.jq", "testdata/operator-gt-out.jq"}, - {"testdata/operator-lt-in.jq", "testdata/operator-lt-out.jq"}, - {"testdata/operator-ge-in.jq", "testdata/operator-ge-out.jq"}, - {"testdata/operator-le-in.jq", "testdata/operator-le-out.jq"}, - {"testdata/operator-and-in.jq", "testdata/operator-and-out.jq"}, - {"testdata/operator-or-in.jq", "testdata/operator-or-out.jq"}, - {"testdata/operator-alt-in.jq", "testdata/operator-alt-out.jq"}, - {"testdata/operator-assign-in.jq", "testdata/operator-assign-out.jq"}, - {"testdata/operator-modify-in.jq", "testdata/operator-modify-out.jq"}, - {"testdata/operator-updateAdd-in.jq", "testdata/operator-updateAdd-out.jq"}, - {"testdata/operator-updateSub-in.jq", "testdata/operator-updateSub-out.jq"}, - {"testdata/operator-updateMul-in.jq", "testdata/operator-updateMul-out.jq"}, - {"testdata/operator-updateDiv-in.jq", "testdata/operator-updateDiv-out.jq"}, - {"testdata/operator-updateMod-in.jq", "testdata/operator-updateMod-out.jq"}, - {"testdata/operator-updateAlt-in.jq", "testdata/operator-updateAlt-out.jq"}, +// operatorNames discovers the operators under test from the fixture files, so a +// new testdata/operator--{in,out}.jq pair is picked up without editing +// this file. +func operatorNames(t *testing.T) []string { + t.Helper() + ins, err := filepath.Glob("testdata/operator-*-in.jq") + if err != nil { + t.Fatalf("glob operators: %v", err) } - - for _, c := range cases { - - op := strings.Split(c.inFile, "-")[1] - - cfg = JqFmtCfg{ - Ops: []string{op}, - } - - inBytes, err := ioutil.ReadFile(c.inFile) - if err != nil { - t.Fatalf("failed to open input file: %s", err) - } - in := string(inBytes) - - wantBytes, err := ioutil.ReadFile(c.outFile) - if err != nil { - t.Fatalf("failed to open want file: %s", err) - } - want := string(wantBytes) - - out, err := DoThing(in, cfg) - if err != nil { - t.Fatalf("could not do thing: %s", err) - } - - if !reflect.DeepEqual(want, out) { - t.Logf("want: %s", want) - t.Logf("have: %s", out) - t.Errorf("%s does not match %s", c.inFile, c.outFile) - } + var ops []string + for _, in := range ins { + name := strings.TrimSuffix(filepath.Base(in), "-in.jq") + ops = append(ops, strings.TrimPrefix(name, "operator-")) } + return ops } -// func TestFunction(t *testing.T) { -// cases := []struct { -// inFile string -// outFile string -// }{ -// {"testdata/function-map-in.jq", "testdata/function-map-out.jq"}, -// } -// -// for _, c := range cases { -// -// fn := strings.Split(c.inFile, "-")[1] -// -// cfg = JqFmtCfg{ -// Funcs: []string{fn}, -// } -// -// inBytes, err := ioutil.ReadFile(c.inFile) -// if err != nil { -// t.Fatalf("failed to open input file: %s", err) -// } -// in := string(inBytes) -// -// wantBytes, err := ioutil.ReadFile(c.outFile) -// if err != nil { -// t.Fatalf("failed to open want file: %s", err) -// } -// want := string(wantBytes) -// -// out, err := DoThing(in, cfg) -// if err != nil { -// t.Fatalf("could not do thing: %s", err) -// } -// -// if !reflect.DeepEqual(want, out) { -// t.Logf("want: %s", want) -// t.Logf("have: %s", out) -// t.Errorf("%s does not match %s", c.inFile, c.outFile) -// } -// } -// } - -func TestMulti(t *testing.T) { - cases := []struct { - inFile string - outFile string - }{ - {"testdata/multi-1-in.jq", "testdata/multi-1-out.jq"}, +// functionNames discovers the function fixtures the same way operatorNames does, +// so a new testdata/function--{in,out}.jq pair is picked up automatically. +func functionNames(t *testing.T) []string { + t.Helper() + ins, err := filepath.Glob("testdata/function-*-in.jq") + if err != nil { + t.Fatalf("glob functions: %v", err) } - - for _, c := range cases { - - // n := strings.Split(c.inFile, "-")[1] - - cfg = JqFmtCfg{ - Ops: []string{"pipe"}, - Arr: true, - } - - inBytes, err := ioutil.ReadFile(c.inFile) - if err != nil { - t.Fatalf("failed to open input file: %s", err) - } - in := string(inBytes) - - wantBytes, err := ioutil.ReadFile(c.outFile) - if err != nil { - t.Fatalf("failed to open want file: %s", err) - } - want := string(wantBytes) - - out, err := DoThing(in, cfg) - if err != nil { - t.Fatalf("could not do thing: %s", err) - } - // fmt.Printf("want: %x\n", []byte(want)) - // fmt.Printf("out: %x\n", []byte(out)) - - if !reflect.DeepEqual(want, out) { - t.Logf("want: %d %s", len(want), want) - t.Logf("have: %d %s", len(out), out) - t.Errorf("%s does not match %s", c.inFile, c.outFile) - } + var fns []string + for _, in := range ins { + name := strings.TrimSuffix(filepath.Base(in), "-in.jq") + fns = append(fns, strings.TrimPrefix(name, "function-")) } + return fns } -func TestTrailingWhitespace(t *testing.T) { - cfg = JqFmtCfg{ - Arr: true, - Obj: true, - Ops: []string{ - "pipe", - "comma", - "add", - "sub", - "mul", - "div", - "mod", - "eq", - "ne", - "gt", - "lt", - "ge", - "le", - "and", - "or", - "alt", - "assign", - "modify", - "updateAdd", - "updateSub", - "updateMul", - "updateDiv", - "updateMod", - "updateAlt", - }, - } - - inBytes, err := ioutil.ReadFile("testdata/trailing-space-in.jq") - if err != nil { - t.Fatalf("failed to open input file: %s", err) - } - in := string(inBytes) - - wantBytes, err := ioutil.ReadFile("testdata/trailing-space-out.jq") - if err != nil { - t.Fatalf("failed to open want file: %s", err) - } - want := string(wantBytes) +func TestArray(t *testing.T) { + checkFormat(t, Config{Arr: true}, "testdata/array-in.jq", "testdata/array-out.jq") +} - out, err := DoThing(in, cfg) - if err != nil { - t.Fatalf("could not do thing: %s", err) - } +func TestObject(t *testing.T) { + checkFormat(t, Config{Obj: true}, "testdata/object-in.jq", "testdata/object-out.jq") +} - if !reflect.DeepEqual(want, out) { - t.Logf("want: %s", want) - t.Logf("have: %s", out) - t.Errorf("testdata/trailing-space-in.jq does not match testdata/trailing-space-out.jq") - } +func TestIf(t *testing.T) { + checkFormat(t, Config{If: true}, "testdata/if-in.jq", "testdata/if-out.jq") } func TestFuncDef(t *testing.T) { - cases := []struct { - inFile string - outFile string - }{ - {"testdata/funcdef-in.jq", "testdata/funcdef-out.jq"}, - } - - cfg = JqFmtCfg{} - - for _, c := range cases { - inBytes, err := ioutil.ReadFile(c.inFile) - if err != nil { - t.Fatalf("failed to open input file: %s", err) - } - in := string(inBytes) - - wantBytes, err := ioutil.ReadFile(c.outFile) - if err != nil { - t.Fatalf("failed to open want file: %s", err) - } - want := string(wantBytes) - - out, err := DoThing(in, cfg) - if err != nil { - t.Fatalf("could not do thing: %s", err) - } + checkFormat(t, Config{}, "testdata/funcdef-in.jq", "testdata/funcdef-out.jq") +} - if !reflect.DeepEqual(want, out) { - t.Logf("want: %s", want) - t.Logf("have: %s", out) - t.Errorf("%s does not match %s", c.inFile, c.outFile) - } +// TestOperator breaks on one operator at a time, over every operator fixture. +func TestOperator(t *testing.T) { + for _, op := range operatorNames(t) { + t.Run(op, func(t *testing.T) { + checkFormat(t, Config{Ops: []string{op}}, + "testdata/operator-"+op+"-in.jq", + "testdata/operator-"+op+"-out.jq") + }) } } -/* +// TestFunction breaks the line before a named function call, over every +// function fixture. func TestFunction(t *testing.T) { - cases := []struct { - inFile string - outFile string - }{ - {"testdata/function-map-in.jq", "testdata/function-map-out.jq"}, - } - - for _, c := range cases { - - fn := strings.Split(c.inFile, "-")[1] - - cfg = JqFmtCfg{ - Funcs: []string{fn}, - } - - inBytes, err := ioutil.ReadFile(c.inFile) - if err != nil { - t.Fatalf("failed to open input file: %s", err) - } - in := string(inBytes) - - wantBytes, err := ioutil.ReadFile(c.outFile) - if err != nil { - t.Fatalf("failed to open want file: %s", err) - } - want := string(wantBytes) - - out, err := DoThing(in, cfg) - if err != nil { - t.Fatalf("could not do thing: %s", err) - } - - if !reflect.DeepEqual(want, out) { - t.Logf("want: %s", want) - t.Logf("have: %s", out) - t.Errorf("%s does not match %s", c.inFile, c.outFile) - } + for _, fn := range functionNames(t) { + t.Run(fn, func(t *testing.T) { + checkFormat(t, Config{Funcs: []string{fn}}, + "testdata/function-"+fn+"-in.jq", + "testdata/function-"+fn+"-out.jq") + }) } } -*/ - -// TestIf covers breaking a conditional: each of elif/else/end starts its own -// line, aligned with the "if". -func TestIf(t *testing.T) { - cases := []struct { - inFile string - outFile string - }{ - {"testdata/if-in.jq", "testdata/if-out.jq"}, - } - - cfg = JqFmtCfg{ - If: true, - } - - for _, c := range cases { - inBytes, err := ioutil.ReadFile(c.inFile) - if err != nil { - t.Fatalf("failed to open input file: %s", err) - } - in := string(inBytes) - wantBytes, err := ioutil.ReadFile(c.outFile) - if err != nil { - t.Fatalf("failed to open want file: %s", err) - } - want := string(wantBytes) - - out, err := DoThing(in, cfg) - if err != nil { - t.Fatalf("could not do thing: %s", err) - } +// TestMulti combines pipe breaking with array breaking. +func TestMulti(t *testing.T) { + checkFormat(t, Config{Ops: []string{"pipe"}, Arr: true}, + "testdata/multi-1-in.jq", "testdata/multi-1-out.jq") +} - if !reflect.DeepEqual(want, out) { - t.Logf("want: %s", want) - t.Logf("have: %s", out) - t.Errorf("%s does not match %s", c.inFile, c.outFile) - } - } +// TestTrailingWhitespace turns everything on at once and checks no line is left +// with trailing spaces. +func TestTrailingWhitespace(t *testing.T) { + checkFormat(t, Config{Arr: true, Obj: true, Ops: operatorNames(t)}, + "testdata/trailing-space-in.jq", "testdata/trailing-space-out.jq") } diff --git a/lib.go b/lib.go deleted file mode 100644 index e546fe2..0000000 --- a/lib.go +++ /dev/null @@ -1,1733 +0,0 @@ -package jqfmt - -// TODO: Clean this up, pull only what's required from each file, and copypaste -// as much as possible without prepending "gojq." to various things. - -import ( - "bytes" - "encoding/json" - "fmt" - "io" - "math" - "math/big" - // "regexp" - "slices" - "sort" - "strconv" - "strings" - "unicode/utf8" - - "github.com/itchyny/gojq" - log "github.com/sirupsen/logrus" -) - -// Misc -// ---------------------------------------- - -func toNumber(v string) interface{} { - return normalizeNumber(json.Number(v)) -} - -func funcOpNegate(v interface{}) interface{} { - switch v := v.(type) { - case int: - return -v - case float64: - return -v - case *big.Int: - return new(big.Int).Neg(v) - default: - return &unaryTypeError{"negate", v} - } -} - -type unaryTypeError struct { - name string - v interface{} -} - -// Query -// ---------------------------------------- - -// Query represents the abstract syntax tree of a jq query. -type Query struct { - Meta *ConstObject - Imports []*Import - FuncDefs []*FuncDef - Term *Term - Left *Query - Op gojq.Operator - Right *Query - Func string -} - -func (e *Query) String() string { - var s strings.Builder - e.writeTo(&s) - return s.String() -} - -func nodeIdt(nodeToIdt, reason string) { - log.Debugf("indenting \"%s\" for \"%s\" because \"%s\"\n", nodeToIdt, node, reason) - nodeIdts[nodeToIdt] = append(nodeIdts[nodeToIdt], reason) -} - -func prtIdt(s *strings.Builder) { - if node == ".Identity" { - return - } - if strings.HasSuffix(node, ".Query.Left.Func") { - return - } - // idtHist := map[string]int{} - if indented[line] == 0 { - cIdt := 0 - for n, reason := range nodeIdts { - if node == n { - continue - } - if strings.HasPrefix(node, n) { - log.Debugf("indt: \"%s\" -- %s\n", n, strings.Join(reason[:], ", ")) - cIdt += 1 - } - } - idtStr := "" - log.Debugf("indt: %d\tnode: \"%s\"\n", cIdt, node) - for i := 0; i < cIdt; i++ { - idtStr += " " - } - s.WriteString(idtStr) - indented[line] = 1 - } else { - } -} - -func trimTrailingSpace(s *strings.Builder) { - out := s.String() - if len(out) == 0 { - return - } - last := out[len(out)-1] - if last != ' ' && last != '\t' { - return - } - trimmed := strings.TrimRight(out, " \t") - s.Reset() - s.WriteString(trimmed) -} - -func brk(s *strings.Builder) { - trimTrailingSpace(s) - s.WriteByte('\n') - // idtStr := "" - // for i := 0; i < idt; i++ { - // // idtStr += " " - // } - // s.WriteString(idtStr) - line += 1 -} - -func descendsFrom(node string, ancestor string, parents []string) (bool, string) { - nodeParts := strings.Split(node, ".") - descendsFrom := true - var n int - for n = len(nodeParts) - 1; n >= 0; n-- { - if nodeParts[n] == ancestor { - break - } - parentValid := false - for _, parent := range parents { - if nodeParts[n] == parent { - parentValid = true - } - } - if !parentValid || n == 0 { - descendsFrom = false - } - } - return descendsFrom, strings.Join(nodeParts[:n+1], ".") -} - -func (e *Query) writeTo(s *strings.Builder) { - prevNode := node - // if e.Term != nil && e.Term.String() == "." { - if node == "" { - log.Debugln("----------------------------------------") - } - log.Debugln("---") - // log.Debugf("node: %q\n", node) - // log.Debugln("nodeIdts:", nodeIdts) - - // PrintJSON(e) - - // Where are we in the syntax tree? - arrElem, _ := descendsFrom(node, "Array", []string{"", "Left", "Right"}) - firstQueryTerm, firstQueryAncestor := descendsFrom(node, "Query", []string{"Left"}) // needs Left/Right? - // topQueryTerm, topQueryAncestor := descendsFrom(node, "", []string{"", "Left", "Right"}) - topQueryTerm, topQueryAncestor := descendsFrom(node, "", []string{"", "Left", "Query"}) - // log.Debugln("top:", topQueryTerm, "\tfirst:", firstQueryTerm, "\tnode:", node) - log.Debugf("top: %t\tfirst: %t \tnode: %q\n", topQueryTerm, firstQueryTerm, node) - // log.Debugln("first:", firstQueryTerm) - - if e.Meta != nil { - s.WriteString("module ") - node += ".Meta" - e.Meta.writeTo(s) - node = prevNode - s.WriteString(";\n") - } - for _, im := range e.Imports { - node += ".Imports" - im.writeTo(s) - node = prevNode - } - for i, fd := range e.FuncDefs { - // if _, ok := funcDefs[fd.Name]; !ok { - // funcDefs[fd.Name] = fd.String() - // } - if i > 0 { - s.WriteByte(' ') - } - node += ".FuncDefs" - fd.writeTo(s) - node = prevNode - } - if len(e.FuncDefs) > 0 { - s.WriteByte(' ') - } - if e.Func != "" { - s.WriteString(e.Func) - } else if e.Term != nil { - // if e.Term.Func != nil { - // found := false - // for _, fn := range funcs { - // if fn == e.Term.Func.Name { - // found = true - // } - // } - // if !found { - // funcs = append(funcs, e.Term.Func.Name) - // } - // } - node += fmt.Sprintf(".%s", strings.Replace(e.Term.Type.GoString(), "gojq.TermType", "", 1)) - prtIdt(s) - log.Debugf("term: %q\n", e.Term) - e.Term.writeTo(s) - node = prevNode - } else if e.Right != nil { - - node += ".Left" - e.Left.writeTo(s) - node = prevNode - - if true { - - if e.Op == gojq.OpComma { - s.WriteString(", ") - } else { - s.WriteByte(' ') - s.WriteString(e.Op.String()) - s.WriteByte(' ') - } - - // Break on comma-separated array elements. - if cfg.Arr && arrElem && e.Op == gojq.OpComma { - brk(s) - } - - // if e.Op == gojq.OpComma { - // log.Debugln("COMMA!") - // } - - opStr := e.Op.GoString() - - for _, op := range cfg.Ops { - - if opStr == fmt.Sprintf("gojq.Op%s", strings.Title(op)) { - - // log.Debugln("HERE!") - - // Does this order matter? - ancestor := "" - if topQueryTerm { - ancestor = topQueryAncestor - } - if firstQueryTerm { - ancestor = firstQueryAncestor - } - - // log.Debugf("ancestor: \"%s\"\n", ancestor) - // if (firstQueryTerm || topQueryTerm) && queries[ancestor] == 0 { - // if (firstQueryTerm || topQueryTerm) && queries[ancestor] == 0 { - if firstQueryTerm || topQueryTerm { - // Don't indent twice for a query at the beginning of - // the command string. - // match, err := regexp.MatchString("(.Left)+.Query(.Left)+", node) - // match, err := regexp.MatchString("Left", node) - // if err != nil { - // panic(err) - // } - // if match { - // // log.Debugln("HERE2!!!") - // // break - // } - // queries[ancestor] = 1 - // log.Debugln("here") - // nodeIdts[ancestor+".Right"] = "first query term" - // nodeIdts[ancestor+".Right"] = append(nodeIdts[ancestor+".Right"], "first query term") - nodeIdt(ancestor+".Right", "first query term") - - } // else { - - // nodeIdts[ancestor+".Left"] = fmt.Sprintf("%s operator", op) - // nodeIdts[ancestor+".Left"] = append(nodeIdts[ancestor+".Left"], fmt.Sprintf("%s operator", op)) - if e.Op != gojq.OpPipe { // Put this check here because arrays were getting indented twice. This seems to fix it. - nodeIdt(ancestor+".Left", fmt.Sprintf("%s operator", op)) - } - // } - // if e.Op == gojq.OpComma { - // if !arrElem { - // // nodeIdts[ancestor+".Left.Right"] = 1 - // nodeIdts[ancestor+".Left"] = 1 - // } else { - // continue - // } - // } - brk(s) - } - } - } - - node += ".Right" - e.Right.writeTo(s) - node = prevNode - } -} - -func (e *Query) minify() { - for _, e := range e.FuncDefs { - e.Minify() - } - if e.Term != nil { - if name := e.Term.toFunc(); name != "" { - e.Term = nil - e.Func = name - } else { - e.Term.minify() - } - } else if e.Right != nil { - e.Left.minify() - e.Right.minify() - } -} - -func (e *Query) toIndexKey() interface{} { - if e.Term == nil { - return nil - } - return e.Term.toIndexKey() -} - -func (e *Query) toIndices(xs []interface{}) []interface{} { - if e.Term == nil { - return nil - } - return e.Term.toIndices(xs) -} - -// Import ... -type Import struct { - ImportPath string - ImportAlias string - IncludePath string - Meta *ConstObject -} - -func (e *Import) String() string { - var s strings.Builder - e.writeTo(&s) - return s.String() -} - -func (e *Import) writeTo(s *strings.Builder) { - if e.ImportPath != "" { - s.WriteString("import ") - jsonEncodeString(s, e.ImportPath) - s.WriteString(" as ") - s.WriteString(e.ImportAlias) - } else { - s.WriteString("include ") - jsonEncodeString(s, e.IncludePath) - } - if e.Meta != nil { - s.WriteByte(' ') - e.Meta.writeTo(s) - } - s.WriteString(";\n") -} - -// FuncDef ... -type FuncDef struct { - Name string - Args []string - Body *Query -} - -func (e *FuncDef) String() string { - var s strings.Builder - e.writeTo(&s) - return s.String() -} - -func (e *FuncDef) writeTo(s *strings.Builder) { - s.WriteString("def ") - s.WriteString(e.Name) - if len(e.Args) > 0 { - s.WriteByte('(') - for i, e := range e.Args { - if i > 0 { - s.WriteString("; ") - } - s.WriteString(e) - } - s.WriteByte(')') - } - s.WriteString(": ") - e.Body.writeTo(s) - s.WriteByte(';') -} - -// Minify ... -func (e *FuncDef) Minify() { - e.Body.minify() -} - -// Term ... -type Term struct { - Type gojq.TermType - Index *Index - Func *Func - Object *Object - Array *Array - Number string - Unary *Unary - Format string - Str *String - If *If - Try *Try - Reduce *Reduce - Foreach *Foreach - Label *Label - Break string - Query *Query - SuffixList []*Suffix -} - -func (e *Term) String() string { - var s strings.Builder - e.writeTo(&s) - return s.String() -} - -func (e *Term) writeTo(s *strings.Builder) { - switch e.Type { - case gojq.TermTypeIdentity: - s.WriteByte('.') - case gojq.TermTypeRecurse: - s.WriteString("..") - case gojq.TermTypeNull: - s.WriteString("null") - case gojq.TermTypeTrue: - s.WriteString("true") - case gojq.TermTypeFalse: - s.WriteString("false") - case gojq.TermTypeIndex: - e.Index.writeTo(s) - case gojq.TermTypeFunc: - e.Func.writeTo(s) - case gojq.TermTypeObject: - e.Object.writeTo(s) - case gojq.TermTypeArray: - e.Array.writeTo(s) - case gojq.TermTypeNumber: - s.WriteString(e.Number) - case gojq.TermTypeUnary: - e.Unary.writeTo(s) - case gojq.TermTypeFormat: - s.WriteString(e.Format) - if e.Str != nil { - s.WriteByte(' ') - e.Str.writeTo(s) - } - case gojq.TermTypeString: - e.Str.writeTo(s) - case gojq.TermTypeIf: - e.If.writeTo(s) - case gojq.TermTypeTry: - e.Try.writeTo(s) - case gojq.TermTypeReduce: - e.Reduce.writeTo(s) - case gojq.TermTypeForeach: - e.Foreach.writeTo(s) - case gojq.TermTypeLabel: - e.Label.writeTo(s) - case gojq.TermTypeBreak: - s.WriteString("break ") - s.WriteString(e.Break) - case gojq.TermTypeQuery: - s.WriteByte('(') - e.Query.writeTo(s) - s.WriteByte(')') - } - for _, e := range e.SuffixList { - e.writeTo(s) - } -} - -func (e *Term) minify() { - switch e.Type { - case gojq.TermTypeIndex: - e.Index.minify() - case gojq.TermTypeFunc: - e.Func.minify() - case gojq.TermTypeObject: - e.Object.minify() - case gojq.TermTypeArray: - e.Array.minify() - case gojq.TermTypeUnary: - e.Unary.minify() - case gojq.TermTypeFormat: - if e.Str != nil { - e.Str.minify() - } - case gojq.TermTypeString: - e.Str.minify() - case gojq.TermTypeIf: - e.If.minify() - case gojq.TermTypeTry: - e.Try.minify() - case gojq.TermTypeReduce: - e.Reduce.minify() - case gojq.TermTypeForeach: - e.Foreach.minify() - case gojq.TermTypeLabel: - e.Label.minify() - case gojq.TermTypeQuery: - e.Query.minify() - } - for _, e := range e.SuffixList { - e.minify() - } -} - -func (e *Term) toFunc() string { - if len(e.SuffixList) != 0 { - return "" - } - // ref: compiler#compileQuery - switch e.Type { - case gojq.TermTypeIdentity: - return "." - case gojq.TermTypeRecurse: - return ".." - case gojq.TermTypeNull: - return "null" - case gojq.TermTypeTrue: - return "true" - case gojq.TermTypeFalse: - return "false" - case gojq.TermTypeFunc: - return e.Func.toFunc() - default: - return "" - } -} - -func (e *Term) toIndexKey() interface{} { - switch e.Type { - case gojq.TermTypeNumber: - return toNumber(e.Number) - case gojq.TermTypeUnary: - return e.Unary.toNumber() - case gojq.TermTypeString: - if e.Str.Queries == nil { - return e.Str.Str - } - return nil - default: - return nil - } -} - -func (e *Term) toIndices(xs []interface{}) []interface{} { - switch e.Type { - case gojq.TermTypeIndex: - if xs = e.Index.toIndices(xs); xs == nil { - return nil - } - case gojq.TermTypeQuery: - if xs = e.Query.toIndices(xs); xs == nil { - return nil - } - default: - return nil - } - for _, s := range e.SuffixList { - if xs = s.toIndices(xs); xs == nil { - return nil - } - } - return xs -} - -func (e *Term) toNumber() interface{} { - if e.Type == gojq.TermTypeNumber { - return toNumber(e.Number) - } - return nil -} - -// Unary ... -type Unary struct { - Op gojq.Operator - Term *Term -} - -func (e *Unary) String() string { - var s strings.Builder - e.writeTo(&s) - return s.String() -} - -func (e *Unary) writeTo(s *strings.Builder) { - s.WriteString(e.Op.String()) - e.Term.writeTo(s) -} - -func (e *Unary) minify() { - e.Term.minify() -} - -func (e *Unary) toNumber() interface{} { - v := e.Term.toNumber() - if v != nil && e.Op == gojq.OpSub { - v = funcOpNegate(v) - } - return v -} - -// Pattern ... -type Pattern struct { - Name string - Array []*Pattern - Object []*PatternObject -} - -func (e *Pattern) String() string { - var s strings.Builder - e.writeTo(&s) - return s.String() -} - -func (e *Pattern) writeTo(s *strings.Builder) { - if e.Name != "" { - s.WriteString(e.Name) - } else if len(e.Array) > 0 { - s.WriteByte('[') - for i, e := range e.Array { - if i > 0 { - s.WriteString(", ") - } - e.writeTo(s) - } - s.WriteByte(']') - } else if len(e.Object) > 0 { - s.WriteByte('{') - for i, e := range e.Object { - if i > 0 { - s.WriteString(", ") - } - e.writeTo(s) - } - s.WriteByte('}') - } -} - -// PatternObject ... -type PatternObject struct { - Key string - KeyString *String - KeyQuery *Query - Val *Pattern -} - -func (e *PatternObject) String() string { - var s strings.Builder - e.writeTo(&s) - return s.String() -} - -func (e *PatternObject) writeTo(s *strings.Builder) { - if e.Key != "" { - s.WriteString(e.Key) - } else if e.KeyString != nil { - e.KeyString.writeTo(s) - } else if e.KeyQuery != nil { - s.WriteByte('(') - e.KeyQuery.writeTo(s) - s.WriteByte(')') - } - if e.Val != nil { - s.WriteString(": ") - e.Val.writeTo(s) - } -} - -// Index ... -type Index struct { - Name string - Str *String - Start *Query - End *Query - IsSlice bool -} - -func (e *Index) String() string { - var s strings.Builder - e.writeTo(&s) - return s.String() -} - -func (e *Index) writeTo(s *strings.Builder) { - if l := s.Len(); l > 0 { - // ". .x" != "..x" and "0 .x" != "0.x" - if c := s.String()[l-1]; c == '.' || '0' <= c && c <= '9' { - s.WriteByte(' ') - } - } - s.WriteByte('.') - e.writeSuffixTo(s) -} - -func (e *Index) writeSuffixTo(s *strings.Builder) { - if e.Name != "" { - s.WriteString(e.Name) - } else if e.Str != nil { - e.Str.writeTo(s) - } else { - s.WriteByte('[') - if e.IsSlice { - if e.Start != nil { - e.Start.writeTo(s) - } - s.WriteByte(':') - if e.End != nil { - e.End.writeTo(s) - } - } else { - e.Start.writeTo(s) - } - s.WriteByte(']') - } -} - -func (e *Index) minify() { - if e.Str != nil { - e.Str.minify() - } - if e.Start != nil { - e.Start.minify() - } - if e.End != nil { - e.End.minify() - } -} - -func (e *Index) toIndexKey() interface{} { - if e.Name != "" { - return e.Name - } else if e.Str != nil { - if e.Str.Queries == nil { - return e.Str.Str - } - } else if !e.IsSlice { - return e.Start.toIndexKey() - } else { - var start, end interface{} - ok := true - if e.Start != nil { - start = e.Start.toIndexKey() - ok = start != nil - } - if e.End != nil && ok { - end = e.End.toIndexKey() - ok = end != nil - } - if ok { - return map[string]interface{}{"start": start, "end": end} - } - } - return nil -} - -func (e *Index) toIndices(xs []interface{}) []interface{} { - if k := e.toIndexKey(); k != nil { - return append(xs, k) - } - return nil -} - -// Func ... -type Func struct { - Name string - Args []*Query -} - -func (e *Func) String() string { - var s strings.Builder - e.writeTo(&s) - return s.String() -} - -func (e *Func) writeTo(s *strings.Builder) { - // loadMod(e.Name) - // fmt.Println(mods) - // for _, f := range cfg.Funcs { - // if e.Name == f { - // brk(s) - // } - // } - s.WriteString(e.Name) - if len(e.Args) > 0 { - s.WriteByte('(') - for i, e := range e.Args { - if i > 0 { - s.WriteString("; ") - } - e.writeTo(s) - } - s.WriteByte(')') - } -} - -func (e *Func) minify() { - for _, x := range e.Args { - x.minify() - } -} - -func (e *Func) toFunc() string { - if len(e.Args) != 0 { - return "" - } - return e.Name -} - -// String ... -type String struct { - Str string - Queries []*Query -} - -func (e *String) String() string { - var s strings.Builder - e.writeTo(&s) - return s.String() -} - -func (e *String) writeTo(s *strings.Builder) { - if e.Queries == nil { - jsonEncodeString(s, e.Str) - return - } - s.WriteByte('"') - for _, e := range e.Queries { - if e.Term.Str == nil { - s.WriteString(`\`) - e.writeTo(s) - } else { - es := e.String() - s.WriteString(es[1 : len(es)-1]) - } - } - s.WriteByte('"') -} - -func (e *String) minify() { - for _, e := range e.Queries { - e.minify() - } -} - -// Object ... -type Object struct { - KeyVals []*ObjectKeyVal -} - -func (e *Object) String() string { - var s strings.Builder - e.writeTo(&s) - return s.String() -} - -func (e *Object) writeTo(s *strings.Builder) { - if len(e.KeyVals) == 0 { - s.WriteString("{}") - return - } - s.WriteString("{ ") - if cfg.Obj { - // nodeIdts[node] = "object" - // nodeIdts[node] = append(nodeIdts[node], "object") - nodeIdt(node, "object") - } - for i, kv := range e.KeyVals { - if i > 0 { - s.WriteString(", ") - } - if cfg.Obj { - prevNode := node - node += ".KeyVals" - brk(s) - prtIdt(s) - node = prevNode - } - kv.writeTo(s) - } - if cfg.Obj { - brk(s) - prtIdt(s) - s.WriteString("}") - } else { - s.WriteString(" }") - } -} - -func (e *Object) minify() { - for _, e := range e.KeyVals { - e.minify() - } -} - -// ObjectKeyVal ... -type ObjectKeyVal struct { - Key string - KeyString *String - KeyQuery *Query - Val *ObjectVal -} - -func (e *ObjectKeyVal) String() string { - var s strings.Builder - e.writeTo(&s) - return s.String() -} - -func (e *ObjectKeyVal) writeTo(s *strings.Builder) { - if e.Key != "" { - s.WriteString(e.Key) - } else if e.KeyString != nil { - e.KeyString.writeTo(s) - } else if e.KeyQuery != nil { - s.WriteByte('(') - e.KeyQuery.writeTo(s) - s.WriteByte(')') - } - if cfg.Obj { - } - if e.Val != nil { - s.WriteString(": ") - e.Val.writeTo(s) - } - if cfg.Obj { - } -} - -func (e *ObjectKeyVal) minify() { - if e.KeyString != nil { - e.KeyString.minify() - } else if e.KeyQuery != nil { - e.KeyQuery.minify() - } - if e.Val != nil { - e.Val.minify() - } -} - -// ObjectVal ... -type ObjectVal struct { - Queries []*Query -} - -func (e *ObjectVal) String() string { - var s strings.Builder - e.writeTo(&s) - return s.String() -} - -func (e *ObjectVal) writeTo(s *strings.Builder) { - for i, e := range e.Queries { - if i > 0 { - s.WriteString(" | ") - } - e.writeTo(s) - } -} - -func (e *ObjectVal) minify() { - for _, e := range e.Queries { - e.minify() - } -} - -// Array ... -type Array struct { - Query *Query -} - -func (e *Array) String() string { - var s strings.Builder - e.writeTo(&s) - return s.String() -} - -func (e *Array) writeTo(s *strings.Builder) { - - prtIdt(s) - s.WriteByte('[') - if cfg.Arr { - brk(s) - // nodeIdts[node] = "array" - // nodeIdts[node] = append(nodeIdts[node], "array") - nodeIdt(node, "array") - } - if e.Query != nil { - arrQ := e.Query - arrQ.writeTo(s) - } - if cfg.Arr { - brk(s) - } - prtIdt(s) - s.WriteByte(']') -} - -func (e *Array) minify() { - if e.Query != nil { - e.Query.minify() - } -} - -// Suffix ... -type Suffix struct { - Index *Index - Iter bool - Optional bool - Bind *Bind -} - -func (e *Suffix) String() string { - var s strings.Builder - e.writeTo(&s) - return s.String() -} - -func (e *Suffix) writeTo(s *strings.Builder) { - if e.Index != nil { - if e.Index.Name != "" || e.Index.Str != nil { - e.Index.writeTo(s) - } else { - e.Index.writeSuffixTo(s) - } - } else if e.Iter { - s.WriteString("[]") - } else if e.Optional { - s.WriteByte('?') - } else if e.Bind != nil { - e.Bind.writeTo(s) - } -} - -func (e *Suffix) minify() { - if e.Index != nil { - e.Index.minify() - } else if e.Bind != nil { - e.Bind.minify() - } -} - -func (e *Suffix) toTerm() *Term { - if e.Index != nil { - return &Term{Type: gojq.TermTypeIndex, Index: e.Index} - } else if e.Iter { - return &Term{Type: gojq.TermTypeIdentity, SuffixList: []*Suffix{{Iter: true}}} - } else { - return nil - } -} - -func (e *Suffix) toIndices(xs []interface{}) []interface{} { - if e.Index == nil { - return nil - } - return e.Index.toIndices(xs) -} - -// Bind ... -type Bind struct { - Patterns []*Pattern - Body *Query -} - -func (e *Bind) String() string { - var s strings.Builder - e.writeTo(&s) - return s.String() -} - -func (e *Bind) writeTo(s *strings.Builder) { - for i, p := range e.Patterns { - if i == 0 { - s.WriteString(" as ") - p.writeTo(s) - s.WriteByte(' ') - } else { - s.WriteString("?// ") - p.writeTo(s) - s.WriteByte(' ') - } - } - // A binding's pipe wraps like any other pipe when pipe breaking is on: just - // break the line. The body inherits its indent from the enclosing context, - // the same way a comma-broken array element does. - if slices.Contains(cfg.Ops, "pipe") { - s.WriteString("|") - brk(s) - } else { - s.WriteString("| ") - } - e.Body.writeTo(s) -} - -func (e *Bind) minify() { - e.Body.minify() -} - -// If ... -type If struct { - Cond *Query - Then *Query - Elif []*IfElif - Else *Query -} - -func (e *If) String() string { - var s strings.Builder - e.writeTo(&s) - return s.String() -} - -func (e *If) writeTo(s *strings.Builder) { - // When breaking, elif/else/end each start their own line at the if's own - // indent — a closer lines up with its opener, like fi/end/done elsewhere. - // prtIdt writes that indent the same way it does for every other line. - ifBrk := func() { - brk(s) - prtIdt(s) - } - s.WriteString("if ") - e.Cond.writeTo(s) - s.WriteString(" then ") - e.Then.writeTo(s) - for _, elif := range e.Elif { - if cfg.If { - ifBrk() - } else { - s.WriteByte(' ') - } - elif.writeTo(s) - } - if e.Else != nil { - if cfg.If { - ifBrk() - s.WriteString("else ") - } else { - s.WriteString(" else ") - } - e.Else.writeTo(s) - } - if cfg.If { - ifBrk() - s.WriteString("end") - } else { - s.WriteString(" end") - } -} - -func (e *If) minify() { - e.Cond.minify() - e.Then.minify() - for _, x := range e.Elif { - x.minify() - } - if e.Else != nil { - e.Else.minify() - } -} - -// IfElif ... -type IfElif struct { - Cond *Query - Then *Query -} - -func (e *IfElif) String() string { - var s strings.Builder - e.writeTo(&s) - return s.String() -} - -func (e *IfElif) writeTo(s *strings.Builder) { - s.WriteString("elif ") - e.Cond.writeTo(s) - s.WriteString(" then ") - e.Then.writeTo(s) -} - -func (e *IfElif) minify() { - e.Cond.minify() - e.Then.minify() -} - -// Try ... -type Try struct { - Body *Query - Catch *Query -} - -func (e *Try) String() string { - var s strings.Builder - e.writeTo(&s) - return s.String() -} - -func (e *Try) writeTo(s *strings.Builder) { - s.WriteString("try ") - e.Body.writeTo(s) - if e.Catch != nil { - s.WriteString(" catch ") - e.Catch.writeTo(s) - } -} - -func (e *Try) minify() { - e.Body.minify() - if e.Catch != nil { - e.Catch.minify() - } -} - -// Reduce ... -type Reduce struct { - Term *Term - Pattern *Pattern - Start *Query - Update *Query -} - -func (e *Reduce) String() string { - var s strings.Builder - e.writeTo(&s) - return s.String() -} - -func (e *Reduce) writeTo(s *strings.Builder) { - s.WriteString("reduce ") - e.Term.writeTo(s) - s.WriteString(" as ") - e.Pattern.writeTo(s) - s.WriteString(" (") - e.Start.writeTo(s) - s.WriteString("; ") - e.Update.writeTo(s) - s.WriteByte(')') -} - -func (e *Reduce) minify() { - e.Term.minify() - e.Start.minify() - e.Update.minify() -} - -// Foreach ... -type Foreach struct { - Term *Term - Pattern *Pattern - Start *Query - Update *Query - Extract *Query -} - -func (e *Foreach) String() string { - var s strings.Builder - e.writeTo(&s) - return s.String() -} - -func (e *Foreach) writeTo(s *strings.Builder) { - s.WriteString("foreach ") - e.Term.writeTo(s) - s.WriteString(" as ") - e.Pattern.writeTo(s) - s.WriteString(" (") - e.Start.writeTo(s) - s.WriteString("; ") - e.Update.writeTo(s) - if e.Extract != nil { - s.WriteString("; ") - e.Extract.writeTo(s) - } - s.WriteByte(')') -} - -func (e *Foreach) minify() { - e.Term.minify() - e.Start.minify() - e.Update.minify() - if e.Extract != nil { - e.Extract.minify() - } -} - -// Label ... -type Label struct { - Ident string - Body *Query -} - -func (e *Label) String() string { - var s strings.Builder - e.writeTo(&s) - return s.String() -} - -func (e *Label) writeTo(s *strings.Builder) { - s.WriteString("label ") - s.WriteString(e.Ident) - s.WriteString(" | ") - e.Body.writeTo(s) -} - -func (e *Label) minify() { - e.Body.minify() -} - -// ConstTerm ... -type ConstTerm struct { - Object *ConstObject - Array *ConstArray - Number string - Str string - Null bool - True bool - False bool -} - -func (e *ConstTerm) String() string { - var s strings.Builder - e.writeTo(&s) - return s.String() -} - -func (e *ConstTerm) writeTo(s *strings.Builder) { - if e.Object != nil { - e.Object.writeTo(s) - } else if e.Array != nil { - e.Array.writeTo(s) - } else if e.Number != "" { - s.WriteString(e.Number) - } else if e.Null { - s.WriteString("null") - } else if e.True { - s.WriteString("true") - } else if e.False { - s.WriteString("false") - } else { - jsonEncodeString(s, e.Str) - } -} - -func (e *ConstTerm) toValue() interface{} { - if e.Object != nil { - return e.Object.ToValue() - } else if e.Array != nil { - return e.Array.toValue() - } else if e.Number != "" { - return toNumber(e.Number) - } else if e.Null { - return nil - } else if e.True { - return true - } else if e.False { - return false - } else { - return e.Str - } -} - -// ConstObject ... -type ConstObject struct { - KeyVals []*ConstObjectKeyVal -} - -func (e *ConstObject) String() string { - var s strings.Builder - e.writeTo(&s) - return s.String() -} - -func (e *ConstObject) writeTo(s *strings.Builder) { - if len(e.KeyVals) == 0 { - s.WriteString("{}") - return - } - s.WriteString("{ ") - for i, kv := range e.KeyVals { - if i > 0 { - s.WriteString(", ") - } - kv.writeTo(s) - } - s.WriteString(" }") -} - -// ToValue converts the object to map[string]interface{}. -func (e *ConstObject) ToValue() map[string]interface{} { - if e == nil { - return nil - } - v := make(map[string]interface{}, len(e.KeyVals)) - for _, e := range e.KeyVals { - key := e.Key - if key == "" { - key = e.KeyString - } - v[key] = e.Val.toValue() - } - return v -} - -// ConstObjectKeyVal ... -type ConstObjectKeyVal struct { - Key string - KeyString string - Val *ConstTerm -} - -func (e *ConstObjectKeyVal) String() string { - var s strings.Builder - e.writeTo(&s) - return s.String() -} - -func (e *ConstObjectKeyVal) writeTo(s *strings.Builder) { - if e.Key != "" { - s.WriteString(e.Key) - } else { - s.WriteString(e.KeyString) - } - s.WriteString(": ") - e.Val.writeTo(s) -} - -// ConstArray ... -type ConstArray struct { - Elems []*ConstTerm -} - -func (e *ConstArray) String() string { - var s strings.Builder - e.writeTo(&s) - return s.String() -} - -func (e *ConstArray) writeTo(s *strings.Builder) { - s.WriteByte('[') - for i, e := range e.Elems { - if i > 0 { - s.WriteString(", ") - } - e.writeTo(s) - } - s.WriteByte(']') -} - -func (e *ConstArray) toValue() []interface{} { - v := make([]interface{}, len(e.Elems)) - for i, e := range e.Elems { - v[i] = e.toValue() - } - return v -} - -// Encoder -// ---------------------------------------- - -// Marshal returns the jq-flavored JSON encoding of v. -// -// This method accepts only limited types (nil, bool, int, float64, *big.Int, -// string, []interface{}, and map[string]interface{}) because these are the -// possible types a gojq iterator can emit. This method marshals NaN to null, -// truncates infinities to (+|-) math.MaxFloat64, uses \b and \f in strings, -// and does not escape '<', '>', '&', '\u2028', and '\u2029'. These behaviors -// are based on the marshaler of jq command, and different from json.Marshal in -// the Go standard library. Note that the result is not safe to embed in HTML. -func Marshal(v interface{}) ([]byte, error) { - var b bytes.Buffer - (&encoder{w: &b}).encode(v) - return b.Bytes(), nil -} - -func jsonMarshal(v interface{}) string { - var sb strings.Builder - (&encoder{w: &sb}).encode(v) - return sb.String() -} - -func jsonEncodeString(sb *strings.Builder, v string) { - (&encoder{w: sb}).encodeString(v) -} - -type encoder struct { - w interface { - io.Writer - io.ByteWriter - io.StringWriter - } - buf [64]byte -} - -func (e *encoder) encode(v interface{}) { - switch v := v.(type) { - case nil: - e.w.WriteString("null") - case bool: - if v { - e.w.WriteString("true") - } else { - e.w.WriteString("false") - } - case int: - e.w.Write(strconv.AppendInt(e.buf[:0], int64(v), 10)) - case float64: - e.encodeFloat64(v) - case *big.Int: - e.w.Write(v.Append(e.buf[:0], 10)) - case string: - e.encodeString(v) - case []interface{}: - e.encodeArray(v) - case map[string]interface{}: - e.encodeMap(v) - default: - panic(fmt.Sprintf("invalid type: %[1]T (%[1]v)", v)) - } -} - -// ref: floatEncoder in encoding/json -func (e *encoder) encodeFloat64(f float64) { - if math.IsNaN(f) { - e.w.WriteString("null") - return - } - if f >= math.MaxFloat64 { - f = math.MaxFloat64 - } else if f <= -math.MaxFloat64 { - f = -math.MaxFloat64 - } - fmt := byte('f') - if x := math.Abs(f); x != 0 && x < 1e-6 || x >= 1e21 { - fmt = 'e' - } - buf := strconv.AppendFloat(e.buf[:0], f, fmt, -1, 64) - if fmt == 'e' { - // clean up e-09 to e-9 - if n := len(buf); n >= 4 && buf[n-4] == 'e' && buf[n-3] == '-' && buf[n-2] == '0' { - buf[n-2] = buf[n-1] - buf = buf[:n-1] - } - } - e.w.Write(buf) -} - -// ref: encodeState#string in encoding/json -func (e *encoder) encodeString(s string) { - e.w.WriteByte('"') - start := 0 - for i := 0; i < len(s); { - if b := s[i]; b < utf8.RuneSelf { - if ' ' <= b && b <= '~' && b != '"' && b != '\\' { - i++ - continue - } - if start < i { - e.w.WriteString(s[start:i]) - } - switch b { - case '"': - e.w.WriteString(`\"`) - case '\\': - e.w.WriteString(`\\`) - case '\b': - e.w.WriteString(`\b`) - case '\f': - e.w.WriteString(`\f`) - case '\n': - e.w.WriteString(`\n`) - case '\r': - e.w.WriteString(`\r`) - case '\t': - e.w.WriteString(`\t`) - default: - const hex = "0123456789abcdef" - e.w.WriteString(`\u00`) - e.w.WriteByte(hex[b>>4]) - e.w.WriteByte(hex[b&0xF]) - } - i++ - start = i - continue - } - c, size := utf8.DecodeRuneInString(s[i:]) - if c == utf8.RuneError && size == 1 { - if start < i { - e.w.WriteString(s[start:i]) - } - e.w.WriteString(`\ufffd`) - i += size - start = i - continue - } - i += size - } - if start < len(s) { - e.w.WriteString(s[start:]) - } - e.w.WriteByte('"') -} - -func (e *encoder) encodeArray(vs []interface{}) { - e.w.WriteByte('[') - for i, v := range vs { - if i > 0 { - e.w.WriteByte(',') - } - e.encode(v) - } - e.w.WriteByte(']') -} - -func (e *encoder) encodeMap(vs map[string]interface{}) { - e.w.WriteByte('{') - type keyVal struct { - key string - val interface{} - } - kvs := make([]keyVal, len(vs)) - var i int - for k, v := range vs { - kvs[i] = keyVal{k, v} - i++ - } - sort.Slice(kvs, func(i, j int) bool { - return kvs[i].key < kvs[j].key - }) - for i, kv := range kvs { - if i > 0 { - e.w.WriteByte(',') - } - e.encodeString(kv.key) - e.w.WriteByte(':') - e.encode(kv.val) - } - e.w.WriteByte('}') -} - -// Normalize -// ---------------------------------------- - -func normalizeNumber(v json.Number) interface{} { - if i, err := v.Int64(); err == nil && math.MinInt <= i && i <= math.MaxInt { - return int(i) - } - if strings.ContainsAny(v.String(), ".eE") { - if f, err := v.Float64(); err == nil { - return f - } - } - if bi, ok := new(big.Int).SetString(v.String(), 10); ok { - return bi - } - if strings.HasPrefix(v.String(), "-") { - return math.Inf(-1) - } - return math.Inf(1) -} - -func normalizeNumbers(v interface{}) interface{} { - switch v := v.(type) { - case json.Number: - return normalizeNumber(v) - case *big.Int: - if v.IsInt64() { - if i := v.Int64(); math.MinInt <= i && i <= math.MaxInt { - return int(i) - } - } - return v - case int64: - if math.MinInt <= v && v <= math.MaxInt { - return int(v) - } - return big.NewInt(v) - case int32: - return int(v) - case int16: - return int(v) - case int8: - return int(v) - case uint: - if v <= math.MaxInt { - return int(v) - } - return new(big.Int).SetUint64(uint64(v)) - case uint64: - if v <= math.MaxInt { - return int(v) - } - return new(big.Int).SetUint64(v) - case uint32: - if uint64(v) <= math.MaxInt { - return int(v) - } - return new(big.Int).SetUint64(uint64(v)) - case uint16: - return int(v) - case uint8: - return int(v) - case float32: - return float64(v) - case []interface{}: - for i, x := range v { - v[i] = normalizeNumbers(x) - } - return v - case map[string]interface{}: - for k, x := range v { - v[k] = normalizeNumbers(x) - } - return v - default: - return v - } -} diff --git a/scanner.go b/scanner.go new file mode 100644 index 0000000..6b247a4 --- /dev/null +++ b/scanner.go @@ -0,0 +1,277 @@ +package jqfmt + +import ( + "fmt" + "strings" +) + +// This is the lexer using ideas from Rob Pike's "Lexical Scanning in Go" talk, +// applied to jq. The parser is gojq's. All we need is a token +// stream we can walk to decide where to break lines. + +// tokenKind labels the lexical category of a token. We only carve out the +// categories the formatter actually reasons about: operators to break on, +// brackets to track nesting, and enough structure to tell an array literal +// (which we may break) from an index bracket like flatten[] (which we never do). +type tokenKind int + +const ( + tokenEOF tokenKind = iota + tokenIdent // words: identifiers, keywords, and/or, true/false/null + tokenNumber + tokenString + tokenVar // $foo, $__loc__ + tokenOp // binary, update, and comma operators + tokenDot // . or .. + tokenLParen + tokenRParen + tokenLBracket + tokenRBracket + tokenLBrace + tokenRBrace + tokenColon + tokenSemicolon + tokenQuestion + tokenInterpolationStart // the "\(" that opens a string interpolation + tokenInterpolationEnd // the ")" that closes one +) + +// token is one lexeme: its kind, its text, and the whitespace that preceded it. +// Carrying that leading whitespace lets the formatter reproduce gojq's canonical +// spacing byte-for-byte wherever it doesn't break a line — it just replays +// pre + text. +type token struct { + kind tokenKind + text string + pre string +} + +// String renders a token for debugging: its quoted text, or "EOF" at the end. +func (t token) String() string { + if t.kind == tokenEOF { + return "EOF" + } + return fmt.Sprintf("%q", t.text) +} + +type scanner struct { + input string + start int // start of the in-progress token + pos int // current scan offset + pre string // whitespace preceding the in-progress token + tokens chan token + + // interpolation is a stack of open string interpolations. A jq string can embed + // expressions with \( ... ), those expressions can hold more strings, and + // those can interpolate again — so nesting needs a stack. Each + // entry counts the plain parens open inside that interpolation, which is how + // we tell an inner ")" apart from the one that closes the interpolation. + interpolation []int +} + +// scanState is a step in the scan. Each step consumes some input, emits at most +// one token, and returns the step to run next — or nil to stop. This is the +// state-function-plus-channel shape from the talk, and the goroutine behind it +// is where "concurrent scanning" comes from. +type scanState func(*scanner) scanState + +// scan lexes input on its own goroutine and hands back the token channel. The +// channel closes right after the EOF token. +func scan(input string) chan token { + s := &scanner{input: input, tokens: make(chan token)} + go s.run() + return s.tokens +} + +func (s *scanner) run() { + for step := scanToken; step != nil; { + step = step(s) + } + close(s.tokens) +} + +func (s *scanner) emit(kind tokenKind) { + s.tokens <- token{kind: kind, text: s.input[s.start:s.pos], pre: s.pre} + s.start = s.pos + s.pre = "" // consumed; the next token has no leading space unless we skip some +} + +// at returns the byte at index i, or 0 past the end. Byte-level scanning is +// fine here: every structural character in jq is ASCII, and the bytes inside a +// string literal are copied through untouched. +func (s *scanner) at(i int) byte { + if i >= len(s.input) { + return 0 + } + return s.input[i] +} + +// scanToken is the entry step: skip whitespace (remembering it), then dispatch +// on the next byte to the step that knows how to read that kind of token. +func scanToken(s *scanner) scanState { + ws := s.pos + for s.pos < len(s.input) && isSpace(s.input[s.pos]) { + s.pos++ + } + s.pre = s.input[ws:s.pos] + s.start = s.pos + + if s.pos >= len(s.input) { + s.emit(tokenEOF) + return nil + } + + c := s.input[s.pos] + + // Inside an interpolation, keep an eye on parens: a ")" either closes a + // nested group or ends the interpolation and drops us back into the string. + if len(s.interpolation) > 0 && (c == '(' || c == ')') { + last := len(s.interpolation) - 1 + s.pos++ + if c == '(' { + s.emit(tokenLParen) + s.interpolation[last]++ + return scanToken + } + if s.interpolation[last] > 0 { + s.emit(tokenRParen) + s.interpolation[last]-- + return scanToken + } + s.emit(tokenInterpolationEnd) + s.interpolation = s.interpolation[:last] + return scanStringChunk // resume the string that wrapped this interpolation + } + + switch { + case c == '"': + return scanString + case c == '$': + return scanVar + case c == '@': // @base64, @csv, ... @protocd like functions + return scanWord + case isDigit(c): + return scanNumber + case c == '.' && isDigit(s.at(s.pos+1)): + return scanNumber + case isWordStart(c): + return scanWord + } + return scanSymbol +} + +// scanString starts a string literal: consume the opening quote, then read the +// first literal chunk. +func scanString(s *scanner) scanState { + s.pos++ // opening quote, part of the first chunk's text + return scanStringChunk +} + +// scanStringChunk reads a run of literal string bytes from the current position +// until the string ends at a closing quote, or an interpolation begins at \(. +// It's re-entered after each interpolation closes, so one string emits a chunk, +// then the interpolation's tokens, then the next chunk, and so on. A jq escape +// like \" or \\ is stepped over so a quote inside it doesn't look like the end. +func scanStringChunk(s *scanner) scanState { + for s.pos < len(s.input) { + switch s.input[s.pos] { + case '\\': + if s.at(s.pos+1) == '(' { // start of interpolation + s.emit(tokenString) // literal collected so far + s.pos += 2 // consume \( + s.emit(tokenInterpolationStart) // text is "\(" + s.interpolation = append(s.interpolation, 0) + return scanToken + } + s.pos += 2 // ordinary escape: skip it and what it escapes + continue + case '"': + s.pos++ // closing quote, part of this chunk's text + s.emit(tokenString) + return scanToken + } + s.pos++ + } + s.emit(tokenString) // unterminated — emit what we have and move on + return scanToken +} + +func scanVar(s *scanner) scanState { + s.pos++ // $ + for s.pos < len(s.input) && isWordPart(s.input[s.pos]) { + s.pos++ + } + s.emit(tokenVar) + return scanToken +} + +func scanWord(s *scanner) scanState { + if s.input[s.pos] == '@' { + s.pos++ + } + for s.pos < len(s.input) && isWordPart(s.input[s.pos]) { + s.pos++ + } + s.emit(tokenIdent) + return scanToken +} + +func scanNumber(s *scanner) scanState { + for s.pos < len(s.input) && isNumberPart(s.input[s.pos]) { + s.pos++ + } + s.emit(tokenNumber) + return scanToken +} + +func scanSymbol(s *scanner) scanState { + rest := s.input[s.pos:] + for _, op := range operatorTable { + if strings.HasPrefix(rest, op.symbol) { + s.pos += len(op.symbol) + s.emit(tokenOp) + return scanToken + } + } + + c := s.input[s.pos] + s.pos++ + switch c { + case '.': + if s.at(s.pos) == '.' { // recurse ".." + s.pos++ + } + s.emit(tokenDot) + case '(': + s.emit(tokenLParen) + case ')': + s.emit(tokenRParen) + case '[': + s.emit(tokenLBracket) + case ']': + s.emit(tokenRBracket) + case '{': + s.emit(tokenLBrace) + case '}': + s.emit(tokenRBrace) + case ':': + s.emit(tokenColon) + case ';': + s.emit(tokenSemicolon) + case '?': + s.emit(tokenQuestion) + default: + // Some byte we didn't plan for. Emit it as a word so we never silently + // drop input; canonical jq shouldn't get us here. + s.emit(tokenIdent) + } + return scanToken +} + +func isSpace(c byte) bool { return c == ' ' || c == '\t' || c == '\n' || c == '\r' } +func isDigit(c byte) bool { return c >= '0' && c <= '9' } +func isWordStart(c byte) bool { + return c == '_' || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') +} +func isWordPart(c byte) bool { return isWordStart(c) || isDigit(c) } +func isNumberPart(c byte) bool { return isDigit(c) || c == '.' || c == 'e' || c == 'E' } From accb7177d2f0832d5e8a4c5435b67db541615e2d Mon Sep 17 00:00:00 2001 From: Vasu Nagendra Date: Fri, 24 Jul 2026 22:30:11 -0500 Subject: [PATCH 3/4] Fix nested-break indentation; lead with and/or MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scanner rewrite's `spine` helper was a lossy stand-in for the old indent rule: it never recorded the indent an operator break introduces, so a break nested inside an already-indented construct flushed back to column 0 (e.g. an `and` chain inside a piped `select(...)`). Replace it with a single rule — the indent is how many open groups are currently broken across lines — leaving the frame stack as the formatter's only state. Also break the line *before* `and`/`or` so the operator leads its line, as people write boolean chains. Pipe and comma still trail. Fixtures updated to match. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 28 +++++-- format.go | 119 +++++++++++++++++----------- jqfmt_test.go | 8 ++ testdata/nested-continuation-in.jq | 1 + testdata/nested-continuation-out.jq | 4 + testdata/operator-and-out.jq | 6 +- testdata/operator-or-out.jq | 6 +- testdata/trailing-space-out.jq | 8 +- 8 files changed, 117 insertions(+), 63 deletions(-) create mode 100644 testdata/nested-continuation-in.jq create mode 100644 testdata/nested-continuation-out.jq diff --git a/README.md b/README.md index d858cfc..e445f1c 100644 --- a/README.md +++ b/README.md @@ -39,15 +39,18 @@ go install -v github.com/noperator/jqfmt/cmd/jqfmt@latest 𝄢 jqfmt -h Usage of jqfmt: -ar - arrays + break array literals, one element per line -f string - file - -o one line + read jq from this file instead of stdin + -fn string + comma-separated function names to break the line before + -if + break if/elif/else/end onto their own lines + -o collapse to a single canonical line -ob - objects + break object literals, one key per line -op string - operators - -v verbose + comma-separated operators to break on (pipe,comma,add,...) ``` Let's take this line of jq… @@ -111,6 +114,19 @@ It'll read easier if we also break on pipes. } ``` +Word operators (`and`/`or`) break _before_ the operator, the way you'd write a boolean chain by hand — while pipes and commas still trail. + +``` +𝄢 echo '{name: .n, ok: select(.active and .verified and .paid)}' | + jqfmt -ob -op pipe,and +{ + name: .n, + ok: select(.active + and .verified + and .paid) +} +``` +
Full list of valid operators

diff --git a/format.go b/format.go index 29913ee..51d2996 100644 --- a/format.go +++ b/format.go @@ -87,10 +87,28 @@ func Format(jqStr string, c Config) (string, error) { return breakLines(canonical, c), nil } -// frame tracks one open bracket. broken means we've split its contents across -// lines (a broken array/object), which bumps the indent for what's inside. +// frame tracks one open group — a bracket, brace, paren, or the implicit root. +// broken means the group's contents are split across lines, which is what an +// indent level is: the current indent is just how many open frames are broken. +// A group breaks either because it's a broken array/object literal or because +// we broke an operator inside it. container marks the literal kind, whose closer +// drops onto its own line; an operator break leaves the closer attached. type frame struct { - broken bool + broken bool + container bool +} + +// depth is the current indent, in 4-space units: the number of open frames that +// have been broken. No line history, no per-frame origin — indent is a pure +// function of which enclosing groups are currently split across lines. +func depth(frames []frame) int { + n := 0 + for _, f := range frames { + if f.broken { + n++ + } + } + return n } // breakLines walks the token stream and rebuilds the source with breaks @@ -107,37 +125,51 @@ func breakLines(src string, c Config) string { } var out strings.Builder - var frames []frame - indent := 0 // current base indent, in 4-space units - interp := 0 // string-interpolation depth; we never break inside one - breakNext := -1 // if >= 0, the next token starts a new line at this indent - prev := tokenEOF // previous token kind, for classifying '[' + frames := []frame{{}} // root frame, so top-level operators have a group to break + interp := 0 // string-interpolation depth; we never break inside one + breakNext := -1 // if >= 0, the next token starts a new line at this indent + prev := tokenEOF // previous token kind, for classifying '[' for i, t := range tokens { // Where does this token go — same line, or a fresh one? line := breakNext breakNext = -1 - // A broken bracket drops its closer onto its own line at the parent - // indent (the "]" or "}" lines up under the opener's line). - if isClose(t.kind) && top(frames).broken { - indent-- - line = indent + // A broken literal drops its closer onto its own line at the parent + // indent (the "]" or "}" lines up under the opener). An operator-broken + // group is not a container, so its ")" stays attached to the last operand. + if isClose(t.kind) && top(frames).container { + line = depth(frames) - 1 } // if/elif/else/end: with If on, each of elif/else/end starts its own - // line aligned with the if. Guard on prev != dot so a field named - // ".end" isn't mistaken for the keyword. - if c.If && interp == 0 && len(frames) == 0 && prev != tokenDot && + // line aligned with the if. len(frames)==1 is the top level (root only); + // guard on prev != dot so a field named ".end" isn't mistaken for it. + if c.If && interp == 0 && len(frames) == 1 && prev != tokenDot && t.kind == tokenIdent && isIfCloser(t.text) { - line = indent + line = depth(frames) } // Break the line before a call to a named function (Config.Funcs). The // prev guards skip the program's first token and field access like .map. if interp == 0 && prev != tokenEOF && prev != tokenDot && t.kind == tokenIdent && slices.Contains(c.Funcs, t.text) { - line = indent + line = depth(frames) + } + + // Word operators (and/or) break *before* the operator, so it leads its + // line the way a person writes a boolean chain: + // .a + // and .b + // The first break marks the enclosing group broken (bumping the indent); + // siblings share it, so the chain stays flat. prev != dot guards a field + // literally named ".and". Symbol operators (pipe, comma, ...) trail + // instead, and are handled after they're emitted. + if interp == 0 && prev != tokenDot && + t.kind == tokenIdent && slices.Contains(wordOperators, t.text) && + slices.Contains(c.Ops, t.text) { + frames[len(frames)-1].broken = true + line = depth(frames) } if line >= 0 { @@ -174,26 +206,20 @@ func breakLines(src string, c Config) string { frames = append(frames, frame{}) case tokenLBracket: broken := c.Arr && !isIndexBracket(prev) && !emptyPair(tokens, i, tokenRBracket) - frames = append(frames, frame{broken: broken}) + frames = append(frames, frame{broken: broken, container: broken}) if broken { - indent++ - breakNext = indent + breakNext = depth(frames) } case tokenLBrace: broken := c.Obj && !emptyPair(tokens, i, tokenRBrace) - frames = append(frames, frame{broken: broken}) + frames = append(frames, frame{broken: broken, container: broken}) if broken { - indent++ - breakNext = indent + breakNext = depth(frames) } case tokenRParen, tokenRBracket, tokenRBrace: frames = pop(frames) case tokenOp: - breakNext = opBreak(t.text, frames, indent, c) - case tokenIdent: - if slices.Contains(wordOperators, t.text) && slices.Contains(c.Ops, t.text) { - breakNext = indent + spine(frames) - } + breakNext = opBreak(t.text, frames, c) } prev = t.kind @@ -201,35 +227,34 @@ func breakLines(src string, c Config) string { return out.String() } -// opBreak reports the indent for the line after a broken operator, or -1 to -// keep the next token on the same line. Comma is special: inside a broken -// array/object it's an element separator (always breaks); otherwise it's the -// comma operator and breaks only when "comma" is configured. -func opBreak(op string, frames []frame, indent int, c Config) int { +// opBreak reports the indent for the line after an operator, or -1 to keep the +// next token on the same line. Comma is special: inside a broken array/object +// it's an element separator (always breaks); otherwise it's the comma operator +// and breaks only when "comma" is configured. +func opBreak(op string, frames []frame, c Config) int { if op == "," { - if top(frames).broken { - return indent + if top(frames).container { + return depth(frames) } if slices.Contains(c.Ops, "comma") { - return indent + spine(frames) + return breakOp(frames) } return -1 } if name := opName(op); name != "" && slices.Contains(c.Ops, name) { - return indent + spine(frames) + return breakOp(frames) } return -1 } -// spine adds one indent level for a top-level operator break and none for a -// nested one. That's the quirk in the fixtures: pipes buried inside a function -// call's arguments break but stay flush left, while the top-level pipe indents -// its right-hand side. -func spine(frames []frame) int { - if len(frames) == 0 { - return 1 - } - return 0 +// breakOp marks the enclosing group broken and returns the indent for the +// operator's continuation lines. The first break at a level bumps the indent; +// every sibling operator at that level then sees the group already broken and +// shares the same indent — which is why a pipe chain stays flat instead of +// stair-stepping. +func breakOp(frames []frame) int { + frames[len(frames)-1].broken = true + return depth(frames) } // isIndexBracket reports whether a '[' following prev is an index/iterator diff --git a/jqfmt_test.go b/jqfmt_test.go index 2148f6d..57cb627 100644 --- a/jqfmt_test.go +++ b/jqfmt_test.go @@ -105,6 +105,14 @@ func TestMulti(t *testing.T) { "testdata/multi-1-in.jq", "testdata/multi-1-out.jq") } +// TestNestedContinuation breaks on an operator inside a construct that an outer +// pipe break has already indented. The continuation lines must stay at the +// enclosing indent, not reset to the left margin. +func TestNestedContinuation(t *testing.T) { + checkFormat(t, Config{Ops: []string{"pipe", "and"}}, + "testdata/nested-continuation-in.jq", "testdata/nested-continuation-out.jq") +} + // TestTrailingWhitespace turns everything on at once and checks no line is left // with trailing spaces. func TestTrailingWhitespace(t *testing.T) { diff --git a/testdata/nested-continuation-in.jq b/testdata/nested-continuation-in.jq new file mode 100644 index 0000000..324273b --- /dev/null +++ b/testdata/nested-continuation-in.jq @@ -0,0 +1 @@ +.[] | select(.a != null and .b != 1 and .c != 2) diff --git a/testdata/nested-continuation-out.jq b/testdata/nested-continuation-out.jq new file mode 100644 index 0000000..bcd08f1 --- /dev/null +++ b/testdata/nested-continuation-out.jq @@ -0,0 +1,4 @@ +.[] | + select(.a != null + and .b != 1 + and .c != 2) \ No newline at end of file diff --git a/testdata/operator-and-out.jq b/testdata/operator-and-out.jq index 681bc20..aec6460 100644 --- a/testdata/operator-and-out.jq +++ b/testdata/operator-and-out.jq @@ -1,3 +1,3 @@ -this and - that and - other \ No newline at end of file +this + and that + and other \ No newline at end of file diff --git a/testdata/operator-or-out.jq b/testdata/operator-or-out.jq index 3954fc4..a4fa8e7 100644 --- a/testdata/operator-or-out.jq +++ b/testdata/operator-or-out.jq @@ -1,3 +1,3 @@ -this or - that or - other \ No newline at end of file +this + or that + or other \ No newline at end of file diff --git a/testdata/trailing-space-out.jq b/testdata/trailing-space-out.jq index b67d787..c79e085 100644 --- a/testdata/trailing-space-out.jq +++ b/testdata/trailing-space-out.jq @@ -1,6 +1,6 @@ map(select(has("resource")) | -.resource.github_repository | -to_entries | -map(.value | -map(.name))) | + .resource.github_repository | + to_entries | + map(.value | + map(.name))) | flatten[] \ No newline at end of file From 65b0957ea313a2de52d3f0354f3c04b1a6ba1efd Mon Sep 17 00:00:00 2001 From: Vasu Nagendra Date: Fri, 24 Jul 2026 23:26:48 -0500 Subject: [PATCH 4/4] Fix nested-if breaking and keyword-key handling; harden Format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Break nested if/elif/else/end, not just top-level: drop the len(stack)==1 guard now that depth(stack) gives the right indent at any nesting. - Don't misread keyword-named object keys ({and: 1}, {end: 2}) as keywords/operators — track key vs value position on object groups and gate the breaks on it, no lookahead. - Rename frame/frames to group/stack; it's a stack of open groups, not call frames. - Verify formatting round-trips: re-parse breakLines output and error if it doesn't canonicalize back, so a token bug can't ship silently. Adds TestIfNested and TestObjectKeywordKeys. Co-Authored-By: Claude Opus 4.8 (1M context) --- format.go | 147 +++++++++++++++++----------- jqfmt_test.go | 15 +++ testdata/if-nested-in.jq | 1 + testdata/if-nested-out.jq | 4 + testdata/object-keyword-keys-in.jq | 1 + testdata/object-keyword-keys-out.jq | 1 + 6 files changed, 113 insertions(+), 56 deletions(-) create mode 100644 testdata/if-nested-in.jq create mode 100644 testdata/if-nested-out.jq create mode 100644 testdata/object-keyword-keys-in.jq create mode 100644 testdata/object-keyword-keys-out.jq diff --git a/format.go b/format.go index 51d2996..446cdd2 100644 --- a/format.go +++ b/format.go @@ -84,33 +84,56 @@ func Format(jqStr string, c Config) (string, error) { if c.OneLn { return canonical, nil } - return breakLines(canonical, c), nil + formatted := breakLines(canonical, c) + // breakLines only inserts whitespace, so the result must parse back to the + // same canonical form. If it doesn't, we dropped or garbled a token — fail + // loudly instead of emitting jq that means something else. + check, err := gojq.Parse(formatted) + if err != nil { + return "", fmt.Errorf("formatting produced invalid jq: %w", err) + } + if check.String() != canonical { + return "", fmt.Errorf("formatting changed the query: %q became %q", canonical, check.String()) + } + return formatted, nil } -// frame tracks one open group — a bracket, brace, paren, or the implicit root. -// broken means the group's contents are split across lines, which is what an -// indent level is: the current indent is just how many open frames are broken. -// A group breaks either because it's a broken array/object literal or because -// we broke an operator inside it. container marks the literal kind, whose closer -// drops onto its own line; an operator break leaves the closer attached. -type frame struct { +// A group is one open bracket, brace, paren, or the implicit root. broken means +// its contents are split across lines, which is what an indent level is: the +// current indent is just how many open groups are broken. A group breaks either +// because it's a broken array/object literal or because we broke an operator +// inside it. container marks the literal kind, whose closer drops onto its own +// line; an operator break leaves the closer attached. +type group struct { broken bool container bool + object bool // a {...} literal, whose bare idents can be keys, not keywords + key bool // inside an object, currently at a key slot (before its ':') } -// depth is the current indent, in 4-space units: the number of open frames that -// have been broken. No line history, no per-frame origin — indent is a pure +// depth is the current indent, in 4-space units: the number of open groups that +// have been broken. No line history, no per-group origin — indent is a pure // function of which enclosing groups are currently split across lines. -func depth(frames []frame) int { +func depth(stack []group) int { n := 0 - for _, f := range frames { - if f.broken { + for _, g := range stack { + if g.broken { n++ } } return n } +// atObjectKey reports whether the next ident sits in an object's key slot, where +// jq lets keywords (else, end, and, or, ...) stand as plain keys. The break +// rules below consult this so they treat those words as keywords only when they +// actually are — the state machine already knows we're at a key, so there's no +// need to peek at the token after it. +func atObjectKey(stack []group) bool { + g := top(stack) + return g.object && g.key +} + // breakLines walks the token stream and rebuilds the source with breaks // inserted. The rule is: replay each token's canonical leading whitespace, // except where we decide to break — there we drop the whitespace and start a @@ -125,10 +148,10 @@ func breakLines(src string, c Config) string { } var out strings.Builder - frames := []frame{{}} // root frame, so top-level operators have a group to break - interp := 0 // string-interpolation depth; we never break inside one - breakNext := -1 // if >= 0, the next token starts a new line at this indent - prev := tokenEOF // previous token kind, for classifying '[' + stack := []group{{}} // root group, so top-level operators have a group to break + interp := 0 // string-interpolation depth; we never break inside one + breakNext := -1 // if >= 0, the next token starts a new line at this indent + prev := tokenEOF // previous token kind, for classifying '[' for i, t := range tokens { // Where does this token go — same line, or a fresh one? @@ -138,23 +161,26 @@ func breakLines(src string, c Config) string { // A broken literal drops its closer onto its own line at the parent // indent (the "]" or "}" lines up under the opener). An operator-broken // group is not a container, so its ")" stays attached to the last operand. - if isClose(t.kind) && top(frames).container { - line = depth(frames) - 1 + if isClose(t.kind) && top(stack).container { + line = depth(stack) - 1 } // if/elif/else/end: with If on, each of elif/else/end starts its own - // line aligned with the if. len(frames)==1 is the top level (root only); - // guard on prev != dot so a field named ".end" isn't mistaken for it. - if c.If && interp == 0 && len(frames) == 1 && prev != tokenDot && + // line at the current indent, so it lines up with the branch bodies — + // whether the if is at the top level or nested inside a call like + // select(...). prev != dot skips a field named ".end"; !atObjectKey + // skips a keyword-named key like {end: ...}. + if c.If && interp == 0 && prev != tokenDot && !atObjectKey(stack) && t.kind == tokenIdent && isIfCloser(t.text) { - line = depth(frames) + line = depth(stack) } // Break the line before a call to a named function (Config.Funcs). The - // prev guards skip the program's first token and field access like .map. - if interp == 0 && prev != tokenEOF && prev != tokenDot && + // prev guards skip the program's first token and field access like .map; + // !atObjectKey skips a key like {map: ...}. + if interp == 0 && prev != tokenEOF && prev != tokenDot && !atObjectKey(stack) && t.kind == tokenIdent && slices.Contains(c.Funcs, t.text) { - line = depth(frames) + line = depth(stack) } // Word operators (and/or) break *before* the operator, so it leads its @@ -163,13 +189,13 @@ func breakLines(src string, c Config) string { // and .b // The first break marks the enclosing group broken (bumping the indent); // siblings share it, so the chain stays flat. prev != dot guards a field - // literally named ".and". Symbol operators (pipe, comma, ...) trail - // instead, and are handled after they're emitted. - if interp == 0 && prev != tokenDot && + // named ".and"; !atObjectKey guards a key like {and: ...}. Symbol + // operators (pipe, comma, ...) trail instead, handled after they emit. + if interp == 0 && prev != tokenDot && !atObjectKey(stack) && t.kind == tokenIdent && slices.Contains(wordOperators, t.text) && slices.Contains(c.Ops, t.text) { - frames[len(frames)-1].broken = true - line = depth(frames) + stack[len(stack)-1].broken = true + line = depth(stack) } if line >= 0 { @@ -185,13 +211,13 @@ func breakLines(src string, c Config) string { // allows it. So the interpolation's tokens replay verbatim. if t.kind == tokenInterpolationStart { interp++ - frames = append(frames, frame{}) + stack = append(stack, group{}) prev = t.kind continue } if t.kind == tokenInterpolationEnd { interp-- - frames = pop(frames) + stack = pop(stack) prev = t.kind continue } @@ -203,23 +229,32 @@ func breakLines(src string, c Config) string { // Update nesting and schedule whatever break should follow this token. switch t.kind { case tokenLParen: - frames = append(frames, frame{}) + stack = append(stack, group{}) case tokenLBracket: broken := c.Arr && !isIndexBracket(prev) && !emptyPair(tokens, i, tokenRBracket) - frames = append(frames, frame{broken: broken, container: broken}) + stack = append(stack, group{broken: broken, container: broken}) if broken { - breakNext = depth(frames) + breakNext = depth(stack) } case tokenLBrace: broken := c.Obj && !emptyPair(tokens, i, tokenRBrace) - frames = append(frames, frame{broken: broken, container: broken}) + stack = append(stack, group{broken: broken, container: broken, object: true, key: true}) if broken { - breakNext = depth(frames) + breakNext = depth(stack) + } + case tokenColon: + // past the ':' we're in the value slot; keywords there are keywords + if top(stack).object { + stack[len(stack)-1].key = false } case tokenRParen, tokenRBracket, tokenRBrace: - frames = pop(frames) + stack = pop(stack) case tokenOp: - breakNext = opBreak(t.text, frames, c) + breakNext = opBreak(t.text, stack, c) + // a ',' between an object's pairs returns us to a key slot + if t.text == "," && top(stack).object { + stack[len(stack)-1].key = true + } } prev = t.kind @@ -231,18 +266,18 @@ func breakLines(src string, c Config) string { // next token on the same line. Comma is special: inside a broken array/object // it's an element separator (always breaks); otherwise it's the comma operator // and breaks only when "comma" is configured. -func opBreak(op string, frames []frame, c Config) int { +func opBreak(op string, stack []group, c Config) int { if op == "," { - if top(frames).container { - return depth(frames) + if top(stack).container { + return depth(stack) } if slices.Contains(c.Ops, "comma") { - return breakOp(frames) + return breakOp(stack) } return -1 } if name := opName(op); name != "" && slices.Contains(c.Ops, name) { - return breakOp(frames) + return breakOp(stack) } return -1 } @@ -252,9 +287,9 @@ func opBreak(op string, frames []frame, c Config) int { // every sibling operator at that level then sees the group already broken and // shares the same indent — which is why a pipe chain stays flat instead of // stair-stepping. -func breakOp(frames []frame) int { - frames[len(frames)-1].broken = true - return depth(frames) +func breakOp(stack []group) int { + stack[len(stack)-1].broken = true + return depth(stack) } // isIndexBracket reports whether a '[' following prev is an index/iterator @@ -281,18 +316,18 @@ func emptyPair(tokens []token, i int, closer tokenKind) bool { return i+1 < len(tokens) && tokens[i+1].kind == closer } -func top(frames []frame) frame { - if len(frames) == 0 { - return frame{} +func top(stack []group) group { + if len(stack) == 0 { + return group{} } - return frames[len(frames)-1] + return stack[len(stack)-1] } -func pop(frames []frame) []frame { - if len(frames) == 0 { - return frames +func pop(stack []group) []group { + if len(stack) == 0 { + return stack } - return frames[:len(frames)-1] + return stack[:len(stack)-1] } // ValidateConfig normalizes and checks the operator names, same contract as the diff --git a/jqfmt_test.go b/jqfmt_test.go index 57cb627..48a84be 100644 --- a/jqfmt_test.go +++ b/jqfmt_test.go @@ -72,6 +72,21 @@ func TestIf(t *testing.T) { checkFormat(t, Config{If: true}, "testdata/if-in.jq", "testdata/if-out.jq") } +// TestIfNested breaks an if that sits inside a call — elif/else/end must break +// at the branch indent instead of collapsing onto one line. +func TestIfNested(t *testing.T) { + checkFormat(t, Config{If: true, Ops: []string{"pipe"}}, + "testdata/if-nested-in.jq", "testdata/if-nested-out.jq") +} + +// TestObjectKeywordKeys guards the key/value tracking: jq lets keywords stand as +// object keys, and a bare {end:...} / {and:...} key must not be mistaken for the +// if-closer or the operator and get a spurious break. +func TestObjectKeywordKeys(t *testing.T) { + checkFormat(t, Config{If: true, Ops: []string{"and"}}, + "testdata/object-keyword-keys-in.jq", "testdata/object-keyword-keys-out.jq") +} + func TestFuncDef(t *testing.T) { checkFormat(t, Config{}, "testdata/funcdef-in.jq", "testdata/funcdef-out.jq") } diff --git a/testdata/if-nested-in.jq b/testdata/if-nested-in.jq new file mode 100644 index 0000000..9f1e944 --- /dev/null +++ b/testdata/if-nested-in.jq @@ -0,0 +1 @@ +.x | select(if .a then .b else .c end) \ No newline at end of file diff --git a/testdata/if-nested-out.jq b/testdata/if-nested-out.jq new file mode 100644 index 0000000..0c2e7bf --- /dev/null +++ b/testdata/if-nested-out.jq @@ -0,0 +1,4 @@ +.x | + select(if .a then .b + else .c + end) \ No newline at end of file diff --git a/testdata/object-keyword-keys-in.jq b/testdata/object-keyword-keys-in.jq new file mode 100644 index 0000000..3f5f9f0 --- /dev/null +++ b/testdata/object-keyword-keys-in.jq @@ -0,0 +1 @@ +{end: 1, and: 2} \ No newline at end of file diff --git a/testdata/object-keyword-keys-out.jq b/testdata/object-keyword-keys-out.jq new file mode 100644 index 0000000..0e48860 --- /dev/null +++ b/testdata/object-keyword-keys-out.jq @@ -0,0 +1 @@ +{ end: 1, and: 2 } \ No newline at end of file