├── .github
├── PULL_REQUEST_TEMPLATE.md
└── workflows
│ ├── build.yml
│ ├── docker-image.yml
│ └── go.yml
├── .gitignore
├── Dockerfile
├── LICENSE
├── Makefile
├── README.md
├── cli
└── qq.go
├── codec
├── codec.go
├── codec_test.go
├── csv
│ └── csv.go
├── gron
│ └── gron.go
├── hcl
│ └── hcl.go
├── html
│ └── html.go
├── ini
│ └── ini_codec.go
├── json
│ └── json.go
├── line
│ └── line.go
├── markdown
│ └── markdown.go
├── proto
│ └── proto.go
├── util
│ └── utils.go
└── xml
│ └── xml.go
├── docs
├── TODO.md
├── demo.gif
└── qq.tape
├── go.mod
├── go.sum
├── internal
└── tui
│ └── interactive.go
├── main.go
└── tests
├── example.proto
├── test.csv
├── test.gron
├── test.hcl
├── test.html
├── test.ini
├── test.json
├── test.sh
├── test.tf
├── test.toml
├── test.txt
├── test.xml
└── test.yaml
/.github/PULL_REQUEST_TEMPLATE.md:
--------------------------------------------------------------------------------
1 | ## Description
2 | 🚀 **Issue: (or just decribe the problem if one isn't present for your change)
3 |
4 |
5 |
6 | ## Changes
7 |
8 | - [ ] **🛠️ Code Changes**
9 | - [ ] Implemented feature
10 | - [ ] Fixed bug
11 | - [ ] Refactored component
12 | - [ ] Updated documentation
13 |
14 | - [ ] **🐛 Testing**
15 | - [ ] Added unit tests for new functionality
16 | - [ ] Updated existing tests to reflect changes
17 | - [ ] Ran all tests locally
18 |
19 | - [ ] **📄 Documentation**
20 | - [ ] Updated README or documentation files as needed
21 |
22 | ## Additional Notes
23 |
24 |
--------------------------------------------------------------------------------
/.github/workflows/build.yml:
--------------------------------------------------------------------------------
1 | on:
2 | release:
3 | types: [created]
4 |
5 | permissions:
6 | contents: write
7 | packages: write
8 |
9 | jobs:
10 | release-linux:
11 | name: Release Linux
12 | runs-on: ubuntu-latest
13 | strategy:
14 | matrix:
15 | goarch: [amd64, arm64]
16 | steps:
17 | - uses: actions/checkout@v4
18 | - uses: wangyoucao577/go-release-action@v1
19 | with:
20 | github_token: ${{ secrets.GITHUB_TOKEN }}
21 | goos: linux
22 | goarch: ${{ matrix.goarch }}
23 |
24 | release-darwin:
25 | name: Release macOS
26 | runs-on: ubuntu-latest
27 | strategy:
28 | matrix:
29 | goarch: [amd64, arm64]
30 | steps:
31 | - uses: actions/checkout@v4
32 | - uses: wangyoucao577/go-release-action@v1
33 | with:
34 | github_token: ${{ secrets.GITHUB_TOKEN }}
35 | goos: darwin
36 | goarch: ${{ matrix.goarch }}
37 |
38 | release-windows:
39 | name: Release Windows
40 | runs-on: ubuntu-latest
41 | strategy:
42 | matrix:
43 | goarch: [amd64, arm64]
44 | steps:
45 | - uses: actions/checkout@v4
46 | - uses: wangyoucao577/go-release-action@v1
47 | with:
48 | github_token: ${{ secrets.GITHUB_TOKEN }}
49 | goos: windows
50 | goarch: ${{ matrix.goarch }}
51 | archive_format: zip
52 |
--------------------------------------------------------------------------------
/.github/workflows/docker-image.yml:
--------------------------------------------------------------------------------
1 | name: ci
2 |
3 | on:
4 | push:
5 | branches:
6 | - 'main'
7 | - 'develop'
8 |
9 | jobs:
10 | docker:
11 | runs-on: ubuntu-latest
12 |
13 | steps:
14 | - uses: actions/checkout@v3
15 | name: Check out code
16 |
17 | - name: Set up QEMU
18 | uses: docker/setup-qemu-action@v2
19 | with:
20 | platforms: all
21 |
22 | - name: Set up Docker Buildx
23 | uses: docker/setup-buildx-action@v2
24 | with:
25 | buildkitd-flags: --allow-insecure-entitlement security.insecure --allow-insecure-entitlement network.host
26 |
27 | - name: Log in to Docker Hub
28 | uses: docker/login-action@v2
29 | with:
30 | username: ${{ secrets.DOCKER_USERNAME }}
31 | password: ${{ secrets.DOCKER_PASSWORD }}
32 |
33 | - name: Determine push branch
34 | id: check_branch
35 | run: echo "::set-output name=push_branch::${GITHUB_REF##*/}"
36 |
37 | - name: Build and push Docker image
38 | uses: docker/build-push-action@v4
39 | with:
40 | context: .
41 | platforms: linux/amd64,linux/arm64
42 | push: true
43 | tags: |
44 | jfryy/qq:${{ steps.check_branch.outputs.push_branch }}-${{ github.sha }}
45 | jfryy/qq:${{ steps.check_branch.outputs.push_branch }}-latest
46 |
--------------------------------------------------------------------------------
/.github/workflows/go.yml:
--------------------------------------------------------------------------------
1 | name: "Go Build Make Test"
2 |
3 | on:
4 | push:
5 | branches: [ "main", "develop" ]
6 | pull_request:
7 | branches: [ "main", "develop" ]
8 |
9 | jobs:
10 |
11 | build:
12 | runs-on: ubuntu-latest
13 | steps:
14 | - uses: actions/checkout@v4
15 |
16 | - name: Set up Go
17 | uses: actions/setup-go@v4
18 | with:
19 | go-version: '1.22'
20 |
21 | - name: Build
22 | run: go build -v ./...
23 |
24 | - name: Test
25 | run: time go test -v ./...
26 |
27 | - name: MakeTest
28 | run: time make test
29 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | bin/
2 | .DS_Store
3 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM golang:1.24 AS builder
2 |
3 | WORKDIR /app
4 | COPY . .
5 | ENV CGO_ENABLED=1
6 | RUN go build -o bin/qq -ldflags="-linkmode external -extldflags -static" .
7 |
8 | FROM gcr.io/distroless/static:nonroot
9 | WORKDIR /qq
10 | COPY --from=builder /app/bin/qq ./qq
11 |
12 | ENTRYPOINT ["./qq"]
13 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 JFry
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | SRC = ./
2 | BINARY = qq
3 | DESTDIR = ~/.local/bin
4 |
5 | all: build
6 |
7 |
8 | build:
9 | go build -o bin/$(BINARY) $(SRC)
10 |
11 | test: build
12 | ./tests/test.sh
13 | go test ./codec
14 |
15 | clean:
16 | rm bin/$(BINARY)
17 |
18 | install: test
19 | mkdir -p $(DESTDIR)
20 | cp bin/$(BINARY) $(DESTDIR)
21 |
22 | perf: build
23 | time "./tests/test.sh"
24 |
25 | docker-push:
26 | docker buildx build --platform linux/amd64,linux/arm64 . -t jfryy/qq:latest --push
27 |
28 | .PHONY: all test clean publish
29 |
30 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # qq
2 |
3 | `qq` is a interoperable configuration format transcoder with `jq` query syntax powered by `gojq`. `qq` is multi modal, and can be used as a replacement for `jq` or be interacted with via a repl with autocomplete and realtime rendering preview for building queries.
4 |
5 | ## Usage
6 | Here's some example usage, this emphasizes the interactive mode for demonstrantion, but `qq` is designed for usage in shell scripts.
7 | 
8 |
9 |
10 |
11 | ```sh
12 | # JSON is default in and output.
13 | cat file.${ext} | qq -i ${ext}
14 |
15 | # Extension is parsed, no need for input flag
16 | qq '.' file.xml
17 |
18 | # random example: query xml, grep with gron using qq io and output as json
19 | qq file.xml -o gron | grep -vE "sweet.potatoes" | qq -i gron
20 |
21 | # get some content from a site with html input
22 | curl motherfuckingwebsite.com | bin/qq -i html '.html.body.ul.li[0]'
23 |
24 | # interactive query builder mode on target file
25 | qq . file.json --interactive
26 | ```
27 |
28 | ## Installation
29 | From brew:
30 | ```shell
31 | brew install jfryy/tap/qq
32 | ```
33 |
34 | From [AUR](https://aur.archlinux.org/packages/qq-git) (ArchLinux):
35 | ```shell
36 | yay qq-git
37 | ```
38 |
39 | From source (requires `go` `>=1.22.4`)
40 | ```shell
41 | make install
42 | ```
43 |
44 | Download at releases [here](https://github.com/JFryy/qq/releases).
45 |
46 | Docker quickstart:
47 |
48 | ```shell
49 | # install the image
50 | docker pull jfryy/qq
51 |
52 | # run an example
53 | echo '{"foo":"bar"}' | docker run -i jfryy/qq '.foo = "bazz"' -o tf
54 | ```
55 | ## Background
56 |
57 | `qq` is inspired by `fq` and `jq`. `jq` is a powerful and succinct query tool, sometimes I would find myself needing to use another bespoke tool for another format than json, whether its something dedicated with json query built in or a simple converter from one configuration format to json to pipe into jq. `qq` aims to be a handly utility on the terminal or in shell scripts that can be used for most interaction with structured formats in the terminal. It can transcode configuration formats interchangeably between one-another with the power of `jq` and it has an `an interactive repl (with automcomplete)` to boot so you can have an interactive experience when building queries optionally. Many thanks to the authors of the libraries used in this project, especially `jq`, `gojq`, `gron` and `fq` for direct usage and/or inspiration for the project.
58 |
59 |
60 | ## Features
61 | * support a wide range of configuration formats and transform them interchangeably between eachother.
62 | * quick and comprehensive querying of configuration formats without needing a pipeline of dedicated tools.
63 | * provide an interactive mode for building queries with autocomplete and realtime rendering preview.
64 | * `qq` is broad, but performant encodings are still a priority, execution is quite fast despite covering a broad range of codecs. `qq` performs comparitively with dedicated tools for a given format.
65 |
66 | ### Rough Benchmarks
67 | note: these improvements generally only occur on large files and are miniscule otherwise. qq may be slower than dedicated tools for a given format, but it is pretty fast for a broad range of formats.
68 |
69 | ```shell
70 | $ du -h large-file.json
71 | 25M large-file.json
72 | ```
73 |
74 | ```shell
75 | # gron large file bench
76 |
77 | $ time gron large-file.json --no-sort | rg -v '[1-4]' | gron --ungron --no-sort > /dev/null 2>&1
78 | gron large-file.json --no-sort 2.58s user 0.48s system 153% cpu 1.990 total
79 | rg -v '[1-4]' 0.18s user 0.24s system 21% cpu 1.991 total
80 | gron --ungron --no-sort > /dev/null 2>&1 7.68s user 1.15s system 197% cpu 4.475 total
81 |
82 | $ time qq -o gron large-file.json | rg -v '[1-4]' | qq -i gron > /dev/null 2>&1
83 | qq -o gron large-file.json 0.81s user 0.09s system 128% cpu 0.706 total
84 | rg -v '[1-4]' 0.02s user 0.01s system 5% cpu 0.706 total
85 | qq -i gron > /dev/null 2>&1 0.07s user 0.01s system 11% cpu 0.741 total
86 |
87 | # yq large file bench
88 |
89 | $ time yq large-file.json -M -o yaml > /dev/null 2>&1
90 | yq large-file.json -M -o yaml > /dev/null 2>&1 4.02s user 0.31s system 208% cpu 2.081 total
91 |
92 | $ time qq large-file.json -o yaml > /dev/null 2>&1
93 | qq large-file.json -o yaml > /dev/null 2>&1 2.72s user 0.16s system 190% cpu 1.519 total
94 | ```
95 |
96 | ## Supported Formats
97 | | Format | Input | Output |
98 | |-------------|----------------|----------------|
99 | | JSON | ✅ Supported | ✅ Supported |
100 | | YAML | ✅ Supported | ✅ Supported |
101 | | TOML | ✅ Supported | ✅ Supported |
102 | | XML | ✅ Supported | ✅ Supported |
103 | | INI | ✅ Supported | ✅ Supported |
104 | | HCL | ✅ Supported | ✅ Supported |
105 | | TF | ✅ Supported | ✅ Supported |
106 | | GRON | ✅ Supported | ✅ Supported |
107 | | CSV | ✅ Supported | ✅ Supported |
108 | | Proto (.proto) | ✅ Supported | ❌ Not Supported |
109 | | HTML | ✅ Supported | ✅ Supported |
110 | | TXT (newline)| ✅ Supported | ❌ Not Supported |
111 |
112 |
113 | ## Caveats
114 | 1. `qq` is not a full `jq` replacement, some flags may or may not be supported.
115 | 3. `qq` is under active development, more codecs in the future may be supported along with improvements to `interactive mode`.
116 |
117 |
118 | ## Contributions
119 | All contributions are welcome to `qq`, especially for upkeep/optimization/addition of new encodings.
120 |
121 | ## Thanks and Acknowledgements / Related Projects
122 | This tool would not be possible without the following projects, this project is arguably more of a composition of these projects than a truly original work, with glue code, some dedicated encoders/decoders, and the interactive mode being original work.
123 | Nevertheless, I hope this project can be useful to others, and I hope to contribute back to the community with this project.
124 |
125 | * [gojq](https://github.com/itchyny/gojq): `gojq` is a pure Go implementation of jq. It is used to power the query engine of qq.
126 | * [fq](https://github.com/wader/fq) : fq is a `jq` like tool for querying a wide array of binary formats.
127 | * [jq](https://github.com/jqlang/jq): `jq` is a lightweight and flexible command-line JSON processor.
128 | * [gron](https://github.com/tomnomnom/gron): gron transforms JSON into discrete assignments that are easy to grep.
129 | * [yq](https://github.com/mikefarah/yq): yq is a lightweight and flexible command-line YAML (and much more) processor.
130 | * [goccy](https://github.com/goccy/go-json): goccy has quite a few encoders and decoders for various formats, and is used in the project for some encodings.
131 | * [go-toml](https://github.com/BurntSushi/toml): go-toml is a TOML parser for Golang with reflection.
132 |
--------------------------------------------------------------------------------
/cli/qq.go:
--------------------------------------------------------------------------------
1 | package cli
2 |
3 | import (
4 | "fmt"
5 | "github.com/JFryy/qq/codec"
6 | "github.com/JFryy/qq/internal/tui"
7 | "github.com/itchyny/gojq"
8 | "github.com/spf13/cobra"
9 | "io"
10 | "os"
11 | "path/filepath"
12 | "strings"
13 | )
14 |
15 | func CreateRootCmd() *cobra.Command {
16 | var inputType, outputType string
17 | var rawOutput bool
18 | var interactive bool
19 | var version bool
20 | var help bool
21 | var encodings string
22 | for _, t := range codec.SupportedFileTypes {
23 | encodings += t.Ext.String() + ", "
24 | }
25 | encodings = strings.TrimSuffix(encodings, ", ")
26 | v := "v0.2.5"
27 | desc := fmt.Sprintf("qq is a interoperable configuration format transcoder with jq querying ability powered by gojq. qq is multi modal, and can be used as a replacement for jq or be interacted with via a repl with autocomplete and realtime rendering preview for building queries. Supported formats include %s", encodings)
28 | cmd := &cobra.Command{
29 | Use: "qq [expression] [file] [flags] \n cat [file] | qq [expression] [flags] \n qq -I file",
30 | Short: "qq - JQ processing with conversions for popular configuration formats.",
31 |
32 | Long: desc,
33 | Run: func(cmd *cobra.Command, args []string) {
34 | if version {
35 | fmt.Println("qq version", v)
36 | os.Exit(0)
37 | }
38 | if len(args) == 0 && !cmd.Flags().Changed("input") && !cmd.Flags().Changed("output") && !cmd.Flags().Changed("raw-input") && isTerminal(os.Stdin) {
39 | err := cmd.Help()
40 | if err != nil {
41 | fmt.Println(err)
42 | os.Exit(1)
43 | }
44 | os.Exit(0)
45 | }
46 | handleCommand(args, inputType, outputType, rawOutput, help, interactive)
47 | },
48 | }
49 | cmd.Flags().StringVarP(&inputType, "input", "i", "json", "specify input file type, only required on parsing stdin.")
50 | cmd.Flags().StringVarP(&outputType, "output", "o", "json", "specify output file type by extension name. This is inferred from extension if passing file position argument.")
51 | cmd.Flags().BoolVarP(&rawOutput, "raw-output", "r", false, "output strings without escapes and quotes.")
52 | cmd.Flags().BoolVarP(&help, "help", "h", false, "help for qq")
53 | cmd.Flags().BoolVarP(&version, "version", "v", false, "version for qq")
54 | cmd.Flags().BoolVarP(&interactive, "interactive", "I", false, "interactive mode for qq")
55 |
56 | return cmd
57 | }
58 |
59 | func handleCommand(args []string, inputtype string, outputtype string, rawInput bool, help bool, interactive bool) {
60 | var input []byte
61 | var err error
62 | var expression string
63 | var filename string
64 | if help {
65 | val := CreateRootCmd().Help()
66 | fmt.Println(val)
67 | os.Exit(0)
68 | }
69 |
70 | // handle input with stdin or file
71 | switch len(args) {
72 | case 0:
73 | expression = "."
74 | input, err = io.ReadAll(os.Stdin)
75 | if err != nil {
76 | fmt.Println(err)
77 | os.Exit(1)
78 | }
79 | case 1:
80 | if isFile(args[0]) {
81 | filename = args[0]
82 | expression = "."
83 | // read file content by name
84 | input, err = os.ReadFile(args[0])
85 | if err != nil {
86 | fmt.Println(err)
87 | os.Exit(1)
88 | }
89 |
90 | } else {
91 | expression = args[0]
92 | input, err = io.ReadAll(os.Stdin)
93 | if err != nil {
94 | fmt.Println(err)
95 | os.Exit(1)
96 | }
97 | }
98 | case 2:
99 | filename = args[1]
100 | expression = args[0]
101 | input, err = os.ReadFile(args[1])
102 | if err != nil {
103 | fmt.Println(err)
104 | os.Exit(1)
105 | }
106 |
107 | }
108 |
109 | var inputCodec codec.EncodingType
110 | if filename != "" {
111 | if inputtype == "json" {
112 | inputCodec = inferFileType(filename)
113 | }
114 | } else {
115 | inputCodec, err = codec.GetEncodingType(inputtype)
116 | }
117 | if err != nil {
118 | fmt.Println(err)
119 | os.Exit(1)
120 | }
121 | var data any
122 | err = codec.Unmarshal(input, inputCodec, &data)
123 | if err != nil {
124 | fmt.Println(err)
125 | }
126 |
127 | outputCodec, err := codec.GetEncodingType(outputtype)
128 | if err != nil {
129 | fmt.Println(err)
130 | os.Exit(1)
131 | }
132 |
133 | if !interactive {
134 | query, err := gojq.Parse(expression)
135 | if err != nil {
136 | fmt.Printf("Error parsing jq expression: %v\n", err)
137 | os.Exit(1)
138 | }
139 |
140 | executeQuery(query, data, outputCodec, rawInput)
141 | os.Exit(0)
142 | }
143 |
144 | b, err := codec.Marshal(data, outputCodec)
145 | s := string(b)
146 | if err != nil {
147 | fmt.Println(err)
148 | os.Exit(1)
149 | }
150 |
151 | tui.Interact(s)
152 | os.Exit(0)
153 | }
154 |
155 | func isTerminal(f *os.File) bool {
156 | info, err := f.Stat()
157 | if err != nil {
158 | return false
159 | }
160 | return (info.Mode() & os.ModeCharDevice) != 0
161 | }
162 |
163 | func isFile(path string) bool {
164 | info, err := os.Stat(path)
165 | if err != nil {
166 | return false
167 | }
168 | return !info.IsDir()
169 | }
170 |
171 | func inferFileType(fName string) codec.EncodingType {
172 | ext := strings.ToLower(filepath.Ext(fName))
173 |
174 | for _, t := range codec.SupportedFileTypes {
175 | if ext == "."+t.Ext.String() {
176 | return t.Ext
177 | }
178 | }
179 | return codec.JSON
180 | }
181 |
182 | func executeQuery(query *gojq.Query, data any, fileType codec.EncodingType, rawOut bool) {
183 | iter := query.Run(data)
184 | for {
185 | v, ok := iter.Next()
186 | if !ok {
187 | break
188 | }
189 | if err, ok := v.(error); ok {
190 | fmt.Printf("Error executing jq expression: %v\n", err)
191 | os.Exit(1)
192 | }
193 | b, err := codec.Marshal(v, fileType)
194 | s := string(b)
195 | if err != nil {
196 | fmt.Printf("Error formatting result: %v\n", err)
197 | os.Exit(1)
198 | }
199 | r, _ := codec.PrettyFormat(s, fileType, rawOut)
200 | fmt.Println(r)
201 | }
202 | }
203 |
--------------------------------------------------------------------------------
/codec/codec.go:
--------------------------------------------------------------------------------
1 | package codec
2 |
3 | import (
4 | "bytes"
5 | "fmt"
6 | "github.com/BurntSushi/toml"
7 | "github.com/alecthomas/chroma"
8 | "github.com/alecthomas/chroma/formatters"
9 | "github.com/alecthomas/chroma/lexers"
10 | "github.com/alecthomas/chroma/styles"
11 | "github.com/goccy/go-json"
12 | "github.com/goccy/go-yaml"
13 | "github.com/mattn/go-isatty"
14 | "os"
15 | "strings"
16 | // dedicated codec packages and wrappers where appropriate
17 | "github.com/JFryy/qq/codec/csv"
18 | "github.com/JFryy/qq/codec/gron"
19 | "github.com/JFryy/qq/codec/hcl"
20 | "github.com/JFryy/qq/codec/html"
21 | "github.com/JFryy/qq/codec/ini"
22 | qqjson "github.com/JFryy/qq/codec/json"
23 | "github.com/JFryy/qq/codec/line"
24 | proto "github.com/JFryy/qq/codec/proto"
25 | "github.com/JFryy/qq/codec/xml"
26 | )
27 |
28 | // EncodingType represents the supported encoding types as an enum with a string representation
29 | type EncodingType int
30 |
31 | const (
32 | JSON EncodingType = iota
33 | YAML
34 | YML
35 | TOML
36 | HCL
37 | TF
38 | CSV
39 | XML
40 | INI
41 | GRON
42 | HTML
43 | LINE
44 | TXT
45 | PROTO
46 | )
47 |
48 | func (e EncodingType) String() string {
49 | return [...]string{"json", "yaml", "yml", "toml", "hcl", "tf", "csv", "xml", "ini", "gron", "html", "line", "txt", "proto"}[e]
50 | }
51 |
52 | type Encoding struct {
53 | Ext EncodingType
54 | Unmarshal func([]byte, any) error
55 | Marshal func(any) ([]byte, error)
56 | }
57 |
58 | func GetEncodingType(fileType string) (EncodingType, error) {
59 | fileType = strings.ToLower(fileType)
60 | for _, t := range SupportedFileTypes {
61 | if fileType == t.Ext.String() {
62 | return t.Ext, nil
63 | }
64 | }
65 | return JSON, fmt.Errorf("unsupported file type: %v", fileType)
66 | }
67 |
68 | var (
69 | htm = html.Codec{}
70 | jsn = qqjson.Codec{} // wrapper for go-json marshal
71 | grn = gron.Codec{}
72 | hcltf = hcl.Codec{}
73 | xmll = xml.Codec{}
74 | inii = ini.Codec{}
75 | lines = line.Codec{}
76 | sv = csv.Codec{}
77 | pb = proto.Codec{}
78 | )
79 | var SupportedFileTypes = []Encoding{
80 | {JSON, json.Unmarshal, jsn.Marshal},
81 | {YAML, yaml.Unmarshal, yaml.Marshal},
82 | {YML, yaml.Unmarshal, yaml.Marshal},
83 | {TOML, toml.Unmarshal, toml.Marshal},
84 | {HCL, hcltf.Unmarshal, hcltf.Marshal},
85 | {TF, hcltf.Unmarshal, hcltf.Marshal},
86 | {CSV, sv.Unmarshal, sv.Marshal},
87 | {XML, xmll.Unmarshal, xmll.Marshal},
88 | {INI, inii.Unmarshal, inii.Marshal},
89 | {GRON, grn.Unmarshal, grn.Marshal},
90 | {HTML, htm.Unmarshal, xmll.Marshal},
91 | {LINE, lines.Unmarshal, jsn.Marshal},
92 | {TXT, lines.Unmarshal, jsn.Marshal},
93 | {PROTO, pb.Unmarshal, jsn.Marshal},
94 | }
95 |
96 | func Unmarshal(input []byte, inputFileType EncodingType, data any) error {
97 | for _, t := range SupportedFileTypes {
98 | if t.Ext == inputFileType {
99 | err := t.Unmarshal(input, data)
100 | if err != nil {
101 | return fmt.Errorf("error parsing input: %v", err)
102 | }
103 | return nil
104 | }
105 | }
106 | return fmt.Errorf("unsupported input file type: %v", inputFileType)
107 | }
108 |
109 | func Marshal(v any, outputFileType EncodingType) ([]byte, error) {
110 | for _, t := range SupportedFileTypes {
111 | if t.Ext == outputFileType {
112 | var err error
113 | b, err := t.Marshal(v)
114 | if err != nil {
115 | return b, fmt.Errorf("error marshaling result to %s: %v", outputFileType, err)
116 | }
117 | return b, nil
118 | }
119 | }
120 | return nil, fmt.Errorf("unsupported output file type: %v", outputFileType)
121 | }
122 |
123 | func PrettyFormat(s string, fileType EncodingType, raw bool) (string, error) {
124 | if raw {
125 | var v any
126 | err := Unmarshal([]byte(s), fileType, &v)
127 | if err != nil {
128 | return "", err
129 | }
130 | switch v.(type) {
131 | case map[string]any:
132 | break
133 | case []any:
134 | break
135 | default:
136 | return strings.ReplaceAll(s, "\"", ""), nil
137 | }
138 | }
139 |
140 | if !isatty.IsTerminal(os.Stdout.Fd()) {
141 | return s, nil
142 | }
143 |
144 | var lexer chroma.Lexer
145 | // this a workaround for json lexer while we don't have a marshal function dedicated for these formats.
146 | if fileType == CSV || fileType == HTML || fileType == LINE || fileType == TXT {
147 | lexer = lexers.Get("json")
148 | } else {
149 | lexer = lexers.Get(fileType.String())
150 | if lexer == nil {
151 | lexer = lexers.Fallback
152 | }
153 | }
154 |
155 | if lexer == nil {
156 | return "", fmt.Errorf("unsupported file type for formatting: %v", fileType)
157 | }
158 |
159 | iterator, err := lexer.Tokenise(nil, s)
160 | if err != nil {
161 | return "", fmt.Errorf("error tokenizing input: %v", err)
162 | }
163 |
164 | style := styles.Get("nord")
165 | formatter := formatters.Get("terminal256")
166 | var buffer bytes.Buffer
167 |
168 | err = formatter.Format(&buffer, style, iterator)
169 | if err != nil {
170 | return "", fmt.Errorf("error formatting output: %v", err)
171 | }
172 |
173 | return buffer.String(), nil
174 | }
175 |
--------------------------------------------------------------------------------
/codec/codec_test.go:
--------------------------------------------------------------------------------
1 | package codec
2 |
3 | import (
4 | "fmt"
5 | "github.com/goccy/go-json"
6 | "reflect"
7 | "testing"
8 | )
9 |
10 | func TestGetEncodingType(t *testing.T) {
11 | tests := []struct {
12 | input string
13 | expected EncodingType
14 | }{
15 | {"json", JSON},
16 | {"yaml", YAML},
17 | {"yml", YML},
18 | {"toml", TOML},
19 | {"hcl", HCL},
20 | {"tf", TF},
21 | {"csv", CSV},
22 | {"xml", XML},
23 | {"ini", INI},
24 | {"gron", GRON},
25 | // {"html", HTML},
26 | }
27 |
28 | for _, tt := range tests {
29 | result, err := GetEncodingType(tt.input)
30 | if err != nil {
31 | t.Errorf("unexpected error for type %s: %v", tt.input, err)
32 | } else if result != tt.expected {
33 | t.Errorf("expected %v, got %v", tt.expected, result)
34 | }
35 | }
36 |
37 | unsupportedResult, err := GetEncodingType("unsupported")
38 | if err == nil {
39 | t.Errorf("expected error for unsupported type, got result: %v", unsupportedResult)
40 | }
41 | }
42 |
43 | func TestMarshal(t *testing.T) {
44 | data := map[string]any{"key": "value"}
45 | tests := []struct {
46 | encodingType EncodingType
47 | }{
48 | {JSON}, {YAML}, {YML}, {TOML}, {HCL}, {TF}, {CSV}, {XML}, {INI}, {GRON}, {HTML},
49 | }
50 |
51 | for _, tt := range tests {
52 | // wrap in an interface for things like CSV that require the basic test data be a []map[string]any
53 | var currentData any
54 | currentData = data
55 | if tt.encodingType == CSV {
56 | currentData = []any{data}
57 | }
58 |
59 | _, err := Marshal(currentData, tt.encodingType)
60 | if err != nil {
61 | t.Errorf("marshal failed for %v: %v", tt.encodingType, err)
62 | }
63 | }
64 | }
65 |
66 | func TestUnmarshal(t *testing.T) {
67 | jsonData := `{"key": "value"}`
68 | xmlData := `value`
69 | yamlData := "key: value"
70 | tomlData := "key = \"value\""
71 | gronData := `key = "value";`
72 | tfData := `key = "value"`
73 | // note: html and csv tests are not yet functional
74 | // htmlData := `
value`
75 | // csvData := "key1,key2\nvalue1,value2\nvalue3,value4"
76 |
77 | tests := []struct {
78 | input []byte
79 | encodingType EncodingType
80 | expected any
81 | }{
82 | {[]byte(jsonData), JSON, map[string]any{"key": "value"}},
83 | {[]byte(xmlData), XML, map[string]any{"root": map[string]any{"key": "value"}}},
84 | {[]byte(yamlData), YAML, map[string]any{"key": "value"}},
85 | {[]byte(tomlData), TOML, map[string]any{"key": "value"}},
86 | {[]byte(gronData), GRON, map[string]any{"key": "value"}},
87 | {[]byte(tfData), TF, map[string]any{"key": "value"}},
88 | // {[]byte(htmlData), HTML, map[string]any{"html": map[string]any{"body": map[string]any{"key": "value"}}}},
89 | // {[]byte(csvData), CSV, []map[string]any{
90 | // {"key1": "value1", "key2": "value2"},
91 | // {"key1": "value3", "key2": "value4"},
92 | // }},
93 | }
94 |
95 | for _, tt := range tests {
96 | var data any
97 | err := Unmarshal(tt.input, tt.encodingType, &data)
98 | if err != nil {
99 | t.Errorf("unmarshal failed for %v: %v", tt.encodingType, err)
100 | }
101 |
102 | expectedJSON, _ := json.Marshal(tt.expected)
103 | actualJSON, _ := json.Marshal(data)
104 |
105 | if !reflect.DeepEqual(data, tt.expected) {
106 | fmt.Printf("expected: %s\n", string(expectedJSON))
107 | fmt.Printf("got: %s\n", string(actualJSON))
108 | t.Errorf("%s: expected %v, got %v", tt.encodingType, tt.expected, data)
109 | }
110 | }
111 | }
112 |
--------------------------------------------------------------------------------
/codec/csv/csv.go:
--------------------------------------------------------------------------------
1 | package csv
2 |
3 | import (
4 | "bytes"
5 | "encoding/csv"
6 | "errors"
7 | "fmt"
8 | "github.com/JFryy/qq/codec/util"
9 | "github.com/goccy/go-json"
10 | "io"
11 | "reflect"
12 | "slices"
13 | "strings"
14 | )
15 |
16 | type Codec struct{}
17 |
18 | func (c *Codec) detectDelimiter(input []byte) rune {
19 | lines := bytes.Split(input, []byte("\n"))
20 | if len(lines) < 2 {
21 | return ','
22 | }
23 |
24 | delimiters := []rune{',', ';', '\t', '|', ' '}
25 | var maxDelimiter rune
26 | maxCount := 0
27 |
28 | for _, delimiter := range delimiters {
29 | count := strings.Count(string(lines[0]), string(delimiter))
30 | if count > maxCount {
31 | maxCount = count
32 | maxDelimiter = delimiter
33 | }
34 | }
35 |
36 | if maxCount == 0 {
37 | return ','
38 | }
39 |
40 | return maxDelimiter
41 | }
42 |
43 | func (c *Codec) Marshal(v any) ([]byte, error) {
44 | var buf bytes.Buffer
45 | w := csv.NewWriter(&buf)
46 |
47 | rv := reflect.ValueOf(v)
48 | if rv.Kind() != reflect.Slice {
49 | return nil, errors.New("input data must be a slice")
50 | }
51 |
52 | if rv.Len() == 0 {
53 | return nil, errors.New("no data to write")
54 | }
55 |
56 | firstElem := rv.Index(0).Interface()
57 | firstElemValue, ok := firstElem.(map[string]any)
58 | if !ok {
59 | return nil, errors.New("slice elements must be of type map[string]any")
60 | }
61 |
62 | var headers []string
63 | for key := range firstElemValue {
64 | headers = append(headers, key)
65 | }
66 | slices.Sort(headers)
67 |
68 | if err := w.Write(headers); err != nil {
69 | return nil, fmt.Errorf("error writing CSV headers: %v", err)
70 | }
71 |
72 | for i := 0; i < rv.Len(); i++ {
73 | recordMap := rv.Index(i).Interface().(map[string]any)
74 | row := make([]string, len(headers))
75 | for j, header := range headers {
76 | if value, ok := recordMap[header]; ok {
77 | row[j] = fmt.Sprintf("%v", value)
78 | } else {
79 | row[j] = ""
80 | }
81 | }
82 | if err := w.Write(row); err != nil {
83 | return nil, fmt.Errorf("error writing CSV record: %v", err)
84 | }
85 | }
86 |
87 | w.Flush()
88 |
89 | if err := w.Error(); err != nil {
90 | return nil, fmt.Errorf("error flushing CSV writer: %v", err)
91 | }
92 |
93 | return buf.Bytes(), nil
94 | }
95 |
96 | func (c *Codec) Unmarshal(input []byte, v any) error {
97 | delimiter := c.detectDelimiter(input)
98 | r := csv.NewReader(strings.NewReader(string(input)))
99 | r.Comma = delimiter
100 | r.TrimLeadingSpace = true
101 | headers, err := r.Read()
102 | if err != nil {
103 | return fmt.Errorf("error reading CSV headers: %v", err)
104 | }
105 |
106 | var records []map[string]any
107 | for {
108 | record, err := r.Read()
109 | if err == io.EOF {
110 | break
111 | }
112 | if err != nil {
113 | return fmt.Errorf("error reading CSV record: %v", err)
114 | }
115 |
116 | rowMap := make(map[string]any)
117 | for i, header := range headers {
118 | rowMap[header] = util.ParseValue(record[i])
119 | }
120 | records = append(records, rowMap)
121 | }
122 |
123 | jsonData, err := json.Marshal(records)
124 | if err != nil {
125 | return fmt.Errorf("error marshaling to JSON: %v", err)
126 | }
127 |
128 | if err := json.Unmarshal(jsonData, v); err != nil {
129 | return fmt.Errorf("error unmarshaling JSON: %v", err)
130 | }
131 |
132 | return nil
133 | }
134 |
--------------------------------------------------------------------------------
/codec/gron/gron.go:
--------------------------------------------------------------------------------
1 | package gron
2 |
3 | import (
4 | "bytes"
5 | "fmt"
6 | "github.com/JFryy/qq/codec/util"
7 | "github.com/goccy/go-json"
8 | "reflect"
9 | "strconv"
10 | "strings"
11 | )
12 |
13 | type Codec struct{}
14 |
15 | func (c *Codec) Unmarshal(data []byte, v any) error {
16 | lines := strings.Split(string(data), "\n")
17 | var isArray bool
18 | dataMap := make(map[string]any)
19 | arrayData := make([]any, 0)
20 |
21 | for _, line := range lines {
22 | if len(line) == 0 {
23 | continue
24 | }
25 | parts := strings.SplitN(line, " = ", 2)
26 | if len(parts) != 2 {
27 | return fmt.Errorf("invalid line format: %s", line)
28 | }
29 |
30 | key := strings.TrimSpace(parts[0])
31 | value := strings.Trim(parts[1], `";`)
32 | parsedValue := util.ParseValue(value)
33 |
34 | if strings.HasPrefix(key, "[") && strings.Contains(key, "]") {
35 | isArray = true
36 | }
37 |
38 | c.setValueJSON(dataMap, key, parsedValue)
39 | }
40 |
41 | if isArray && len(dataMap) == 1 {
42 | for _, val := range dataMap {
43 | if arrayVal, ok := val.([]any); ok {
44 | arrayData = arrayVal
45 | }
46 | }
47 | }
48 |
49 | vv := reflect.ValueOf(v)
50 | if vv.Kind() != reflect.Ptr || vv.IsNil() {
51 | return fmt.Errorf("provided value must be a non-nil pointer")
52 | }
53 | if isArray && len(arrayData) > 0 {
54 | vv.Elem().Set(reflect.ValueOf(arrayData))
55 | } else {
56 | vv.Elem().Set(reflect.ValueOf(dataMap))
57 | }
58 |
59 | return nil
60 | }
61 |
62 | func (c *Codec) Marshal(v any) ([]byte, error) {
63 | var buf bytes.Buffer
64 | c.traverseJSON("", v, &buf)
65 | return buf.Bytes(), nil
66 | }
67 |
68 | func (c *Codec) traverseJSON(prefix string, v any, buf *bytes.Buffer) {
69 | rv := reflect.ValueOf(v)
70 | switch rv.Kind() {
71 | case reflect.Map:
72 | for _, key := range rv.MapKeys() {
73 | strKey := fmt.Sprintf("%v", key)
74 | c.traverseJSON(addPrefix(prefix, strKey), rv.MapIndex(key).Interface(), buf)
75 | }
76 | case reflect.Slice:
77 | for i := 0; i < rv.Len(); i++ {
78 | c.traverseJSON(fmt.Sprintf("%s[%d]", prefix, i), rv.Index(i).Interface(), buf)
79 | }
80 | default:
81 | buf.WriteString(fmt.Sprintf("%s = %s;\n", prefix, formatJSONValue(v)))
82 | }
83 | }
84 |
85 | func addPrefix(prefix, name string) string {
86 | if prefix == "" {
87 | return name
88 | }
89 | if strings.Contains(name, "[") && strings.Contains(name, "]") {
90 | return prefix + name
91 | }
92 | return prefix + "." + name
93 | }
94 |
95 | func formatJSONValue(v any) string {
96 | switch val := v.(type) {
97 | case string:
98 | return fmt.Sprintf("%q", val)
99 | case bool:
100 | return strconv.FormatBool(val)
101 | case float64:
102 | return strconv.FormatFloat(val, 'f', -1, 64)
103 | default:
104 | if v == nil {
105 | return "null"
106 | }
107 | data, _ := json.Marshal(v)
108 | return string(data)
109 | }
110 | }
111 |
112 | func (c *Codec) setValueJSON(data map[string]any, key string, value any) {
113 | parts := strings.Split(key, ".")
114 | var m = data
115 | for i, part := range parts {
116 | if i == len(parts)-1 {
117 | if strings.Contains(part, "[") && strings.Contains(part, "]") {
118 | k := strings.Split(part, "[")[0]
119 | index := parseArrayIndex(part)
120 | if _, ok := m[k]; !ok {
121 | m[k] = make([]any, index+1)
122 | }
123 | arr := m[k].([]any)
124 | if len(arr) <= index {
125 | for len(arr) <= index {
126 | arr = append(arr, nil)
127 | }
128 | m[k] = arr
129 | }
130 | arr[index] = value
131 | } else {
132 | m[part] = value
133 | }
134 | } else {
135 | // fix index assignment nested map: this is needs optimization
136 | if strings.Contains(part, "[") && strings.Contains(part, "]") {
137 | k := strings.Split(part, "[")[0]
138 | index := parseArrayIndex(part)
139 | if _, ok := m[k]; !ok {
140 | m[k] = make([]any, index+1)
141 | }
142 | arr := m[k].([]any)
143 | if len(arr) <= index {
144 | for len(arr) <= index {
145 | arr = append(arr, nil)
146 | }
147 | m[k] = arr
148 | }
149 | if arr[index] == nil {
150 | arr[index] = make(map[string]any)
151 | }
152 | m = arr[index].(map[string]any)
153 | } else {
154 | if _, ok := m[part]; !ok {
155 | m[part] = make(map[string]any)
156 | }
157 | m = m[part].(map[string]any)
158 | }
159 | }
160 | }
161 | }
162 |
163 | func parseArrayIndex(part string) int {
164 | indexStr := strings.Trim(part[strings.Index(part, "[")+1:strings.Index(part, "]")], " ")
165 | index, _ := strconv.Atoi(indexStr)
166 | return index
167 | }
168 |
--------------------------------------------------------------------------------
/codec/hcl/hcl.go:
--------------------------------------------------------------------------------
1 | package hcl
2 |
3 | import (
4 | "fmt"
5 | "github.com/goccy/go-json"
6 | "github.com/hashicorp/hcl/v2/hclwrite"
7 | "github.com/tmccombs/hcl2json/convert"
8 | "github.com/zclconf/go-cty/cty"
9 | "log"
10 | )
11 |
12 | type Codec struct{}
13 |
14 | func (c *Codec) Unmarshal(input []byte, v any) error {
15 | opts := convert.Options{}
16 | content, err := convert.Bytes(input, "json", opts)
17 | if err != nil {
18 | return fmt.Errorf("error converting HCL to JSON: %v", err)
19 | }
20 | return json.Unmarshal(content, v)
21 | }
22 |
23 | func (c *Codec) Marshal(v any) ([]byte, error) {
24 | // Ensure the input is wrapped in a map if it's not already
25 | var data map[string]any
26 | switch v := v.(type) {
27 | case map[string]any:
28 | data = v
29 | default:
30 | data = map[string]any{
31 | "data": v,
32 | }
33 | }
34 | hclData, err := c.convertMapToHCL(data)
35 | if err != nil {
36 | return nil, fmt.Errorf("error converting map to HCL: %v", err)
37 | }
38 |
39 | return hclData, nil
40 | }
41 |
42 | func (c *Codec) convertMapToHCL(data map[string]any) ([]byte, error) {
43 | f := hclwrite.NewEmptyFile()
44 | rootBody := f.Body()
45 | c.populateBody(rootBody, data)
46 | return f.Bytes(), nil
47 | }
48 |
49 | func (c *Codec) populateBody(body *hclwrite.Body, data map[string]any) {
50 | for key, value := range data {
51 | switch v := value.(type) {
52 | case map[string]any:
53 | block := body.AppendNewBlock(key, nil)
54 | c.populateBody(block.Body(), v)
55 |
56 | case []any:
57 | if len(v) == 1 {
58 | if singleMap, ok := v[0].(map[string]any); ok {
59 | block := body.AppendNewBlock(key, nil)
60 | c.populateBody(block.Body(), singleMap)
61 | continue
62 | }
63 | }
64 | if len(v) == 0 {
65 | continue
66 | }
67 | tuple := make([]cty.Value, len(v))
68 | for i, elem := range v {
69 | tuple[i] = c.convertToCtyValue(elem)
70 | }
71 | body.SetAttributeValue(key, cty.TupleVal(tuple))
72 |
73 | case string:
74 | body.SetAttributeValue(key, cty.StringVal(v))
75 | case int:
76 | body.SetAttributeValue(key, cty.NumberIntVal(int64(v)))
77 | case int64:
78 | body.SetAttributeValue(key, cty.NumberIntVal(v))
79 | case float64:
80 | body.SetAttributeValue(key, cty.NumberFloatVal(v))
81 | case bool:
82 | body.SetAttributeValue(key, cty.BoolVal(v))
83 | default:
84 | log.Printf("Unsupported type: %T", v)
85 | }
86 | }
87 | }
88 |
89 | func (c *Codec) convertToCtyValue(value any) cty.Value {
90 | switch v := value.(type) {
91 | case string:
92 | return cty.StringVal(v)
93 | case int:
94 | return cty.NumberIntVal(int64(v))
95 | case int64:
96 | return cty.NumberIntVal(v)
97 | case float64:
98 | return cty.NumberFloatVal(v)
99 | case bool:
100 | return cty.BoolVal(v)
101 | case []any:
102 | tuple := make([]cty.Value, len(v))
103 | for i, elem := range v {
104 | tuple[i] = c.convertToCtyValue(elem)
105 | }
106 | return cty.TupleVal(tuple)
107 | case map[string]any:
108 | vals := make(map[string]cty.Value)
109 | for k, elem := range v {
110 | vals[k] = c.convertToCtyValue(elem)
111 | }
112 | return cty.ObjectVal(vals)
113 | default:
114 | log.Printf("Unsupported type: %T", v)
115 | return cty.NilVal
116 | }
117 | }
118 |
--------------------------------------------------------------------------------
/codec/html/html.go:
--------------------------------------------------------------------------------
1 | package html
2 |
3 | import (
4 | "bytes"
5 | "github.com/goccy/go-json"
6 | "golang.org/x/net/html"
7 | "regexp"
8 | "strconv"
9 | "strings"
10 | )
11 |
12 | /*
13 | HTML to Map Converter. These functions do not yet cover conversion to HTML, only from HTML to other arbitrary output formats at this time.
14 | This implementation may have some limitations and may not cover all edge cases.
15 | */
16 |
17 | type Codec struct{}
18 |
19 | func (c *Codec) Unmarshal(data []byte, v any) error {
20 | htmlMap, err := c.HTMLToMap(data)
21 | if err != nil {
22 | return err
23 | }
24 | b, err := json.Marshal(htmlMap)
25 | if err != nil {
26 | return err
27 | }
28 | return json.Unmarshal(b, v)
29 | }
30 |
31 | func decodeUnicodeEscapes(s string) (string, error) {
32 | re := regexp.MustCompile(`\\u([0-9a-fA-F]{4})`)
33 | return re.ReplaceAllStringFunc(s, func(match string) string {
34 | hex := match[2:]
35 | codePoint, err := strconv.ParseInt(hex, 16, 32)
36 | if err != nil {
37 | return match
38 | }
39 | return string(rune(codePoint))
40 | }), nil
41 | }
42 |
43 | func (c *Codec) HTMLToMap(htmlBytes []byte) (map[string]any, error) {
44 | doc, err := html.Parse(bytes.NewReader(htmlBytes))
45 | if err != nil {
46 | return nil, err
47 | }
48 |
49 | // Always handle presence of root html node
50 | var root *html.Node
51 | for node := doc.FirstChild; node != nil; node = node.NextSibling {
52 | if node.Type == html.ElementNode && node.Data == "html" {
53 | root = node
54 | break
55 | }
56 | }
57 |
58 | if root == nil {
59 | return nil, nil
60 | }
61 |
62 | result := c.nodeToMap(root)
63 | if m, ok := result.(map[string]any); ok {
64 | return map[string]any{"html": m}, nil
65 | }
66 | return nil, nil
67 | }
68 |
69 | func (c *Codec) nodeToMap(node *html.Node) any {
70 | m := make(map[string]any)
71 |
72 | // Process attributes if present for node
73 | if node.Attr != nil {
74 | for _, attr := range node.Attr {
75 | // Decode Unicode escape sequences and HTML entities
76 | v, _ := decodeUnicodeEscapes(attr.Val)
77 | m["@"+attr.Key] = v
78 | }
79 | }
80 |
81 | // Recursively process all the children
82 | var childTexts []string
83 | var comments []string
84 | children := make(map[string][]any)
85 | for child := node.FirstChild; child != nil; child = child.NextSibling {
86 | switch child.Type {
87 | case html.TextNode:
88 | text := strings.TrimSpace(child.Data)
89 | if text != "" && !(strings.TrimSpace(text) == "" && strings.ContainsAny(text, "\n\r")) {
90 | text, _ = strings.CutSuffix(text, "\n\r")
91 | text, _ = strings.CutPrefix(text, "\n")
92 | text, _ = decodeUnicodeEscapes(text)
93 | childTexts = append(childTexts, text)
94 | }
95 | case html.CommentNode:
96 | text := strings.TrimSpace(child.Data)
97 | if text != "" && !(strings.TrimSpace(text) == "" && strings.ContainsAny(text, "\n\r")) {
98 | text, _ = strings.CutSuffix(text, "\n\r")
99 | text, _ = strings.CutPrefix(text, "\n")
100 | text = html.UnescapeString(text)
101 | comments = append(comments, text)
102 | }
103 | case html.ElementNode:
104 | childMap := c.nodeToMap(child)
105 | if childMap != nil {
106 | children[child.Data] = append(children[child.Data], childMap)
107 | }
108 | }
109 | }
110 |
111 | // Merge children into one
112 | for key, value := range children {
113 | if len(value) == 1 {
114 | m[key] = value[0]
115 | } else {
116 | m[key] = value
117 | }
118 | }
119 |
120 | // Handle the children's text
121 | if len(childTexts) > 0 {
122 | if len(childTexts) == 1 {
123 | if len(m) == 0 {
124 | return childTexts[0]
125 | }
126 | m["#text"] = childTexts[0]
127 | } else {
128 | m["#text"] = strings.Join(childTexts, " ")
129 | }
130 | }
131 |
132 | // Handle comments
133 | if len(comments) > 0 {
134 | if len(comments) == 1 {
135 | if len(m) == 0 {
136 | return map[string]any{"#comment": comments[0]}
137 | } else {
138 | m["#comment"] = comments[0]
139 | }
140 | } else {
141 | m["#comment"] = comments
142 | }
143 | }
144 |
145 | if len(m) == 0 {
146 | return nil
147 | } else if len(m) == 1 {
148 | if text, ok := m["#text"]; ok {
149 | return text
150 | }
151 | if len(node.Attr) == 0 {
152 | for key, val := range m {
153 | if childMap, ok := val.(map[string]any); ok && len(childMap) == 1 {
154 | return val
155 | }
156 | return map[string]any{key: val}
157 | }
158 | }
159 | }
160 |
161 | return m
162 | }
163 |
--------------------------------------------------------------------------------
/codec/ini/ini_codec.go:
--------------------------------------------------------------------------------
1 | package ini
2 |
3 | import (
4 | "fmt"
5 | "github.com/JFryy/qq/codec/util"
6 | "github.com/mitchellh/mapstructure"
7 | "gopkg.in/ini.v1"
8 | "strings"
9 | )
10 |
11 | type Codec struct{}
12 |
13 | func (c *Codec) Unmarshal(input []byte, v any) error {
14 | cfg, err := ini.Load(input)
15 | if err != nil {
16 | return fmt.Errorf("error unmarshaling INI: %v", err)
17 | }
18 |
19 | data := make(map[string]any)
20 | for _, section := range cfg.Sections() {
21 | if section.Name() == ini.DefaultSection {
22 | continue
23 | }
24 | sectionMap := make(map[string]any)
25 | for _, key := range section.Keys() {
26 | sectionMap[key.Name()] = util.ParseValue(key.Value())
27 | }
28 | data[section.Name()] = sectionMap
29 | }
30 |
31 | return mapstructure.Decode(data, v)
32 | }
33 |
34 | func (c *Codec) Marshal(v any) ([]byte, error) {
35 | data, ok := v.(map[string]any)
36 | if !ok {
37 | return nil, fmt.Errorf("input data is not a map")
38 | }
39 |
40 | cfg := ini.Empty()
41 | for section, sectionValue := range data {
42 | sectionMap, ok := sectionValue.(map[string]any)
43 | if !ok {
44 | continue
45 | }
46 |
47 | sec, err := cfg.NewSection(section)
48 | if err != nil {
49 | return nil, err
50 | }
51 |
52 | for key, value := range sectionMap {
53 | _, err := sec.NewKey(key, fmt.Sprintf("%v", value))
54 | if err != nil {
55 | return nil, err
56 | }
57 | }
58 | }
59 |
60 | var b strings.Builder
61 | _, err := cfg.WriteTo(&b)
62 | if err != nil {
63 | return nil, fmt.Errorf("error writing INI data: %v", err)
64 | }
65 | return []byte(b.String()), nil
66 | }
67 |
--------------------------------------------------------------------------------
/codec/json/json.go:
--------------------------------------------------------------------------------
1 | package json
2 |
3 | import (
4 | "bytes"
5 | "github.com/goccy/go-json"
6 | )
7 |
8 | type Codec struct{}
9 |
10 | func (c *Codec) Marshal(v any) ([]byte, error) {
11 | var buf bytes.Buffer
12 | encoder := json.NewEncoder(&buf)
13 | encoder.SetEscapeHTML(false)
14 | encoder.SetIndent("", " ")
15 | err := encoder.Encode(v)
16 | if err != nil {
17 | return nil, err
18 | }
19 | encodedBytes := bytes.TrimSpace(buf.Bytes())
20 | return encodedBytes, nil
21 | }
22 |
--------------------------------------------------------------------------------
/codec/line/line.go:
--------------------------------------------------------------------------------
1 | package line
2 |
3 | import (
4 | "fmt"
5 | "github.com/JFryy/qq/codec/util"
6 | "github.com/goccy/go-json"
7 | "reflect"
8 | "strings"
9 | )
10 |
11 | type Codec struct{}
12 |
13 | func (c *Codec) Unmarshal(input []byte, v any) error {
14 | lines := strings.Split(strings.TrimSpace(string(input)), "\n")
15 | var parsedLines []any
16 |
17 | for _, line := range lines {
18 | trimmedLine := strings.TrimSpace(line)
19 | parsedValue := util.ParseValue(trimmedLine)
20 | parsedLines = append(parsedLines, parsedValue)
21 | }
22 |
23 | jsonData, err := json.Marshal(parsedLines)
24 | if err != nil {
25 | return fmt.Errorf("error marshaling to JSON: %v", err)
26 | }
27 |
28 | rv := reflect.ValueOf(v)
29 | if rv.Kind() != reflect.Ptr || rv.IsNil() {
30 | return fmt.Errorf("provided value must be a non-nil pointer")
31 | }
32 |
33 | if err := json.Unmarshal(jsonData, rv.Interface()); err != nil {
34 | return fmt.Errorf("error unmarshaling JSON: %v", err)
35 | }
36 |
37 | return nil
38 | }
39 |
--------------------------------------------------------------------------------
/codec/markdown/markdown.go:
--------------------------------------------------------------------------------
1 | package markdown
2 |
3 | import (
4 | "encoding/json"
5 | "errors"
6 | "regexp"
7 | "strings"
8 | )
9 |
10 | type CodeBlock struct {
11 | Lang string `json:"lang"`
12 | Text string `json:"text"`
13 | }
14 |
15 | type Hyperlink struct {
16 | Text string `json:"text"`
17 | URL string `json:"url"`
18 | }
19 |
20 | type Table []map[string]string
21 |
22 | type Codec struct {
23 | Section map[string]any
24 | Subsection map[string]any
25 | InCodeBlock bool
26 | InTable bool
27 | }
28 |
29 | func (m *Codec) Unmarshal(data []byte, v any) error {
30 | if v == nil {
31 | return errors.New("v cannot be nil")
32 | }
33 |
34 | content := m.parseReadme(string(data))
35 |
36 | jsonData, err := json.Marshal(content)
37 | if err != nil {
38 | return err
39 | }
40 |
41 | return json.Unmarshal(jsonData, v)
42 | }
43 |
44 | func (m *Codec) parseHyperlink(line string) *Hyperlink {
45 | re := regexp.MustCompile(`\[(.*?)\]\((.*?)\)`)
46 | matches := re.FindStringSubmatch(line)
47 | if len(matches) == 3 {
48 | return &Hyperlink{
49 | Text: matches[1],
50 | URL: matches[2],
51 | }
52 | }
53 | return nil
54 | }
55 |
56 | func (m *Codec) parseReadme(content string) any {
57 | lines := strings.Split(content, "\n")
58 | sections := make(map[string]any)
59 | var title string
60 | var table Table
61 | var list []string
62 | var orderedList []string
63 | inCodeBlock := false
64 | inTable := false
65 | codeLanguage := ""
66 | codeContent := []string{}
67 | headers := []string{}
68 | inList := false
69 | inOrderedList := false
70 | var currentHeading string
71 | re := regexp.MustCompile("^[1-9]+. ")
72 | for _, line := range lines {
73 | trimmedLine := strings.TrimSpace(line)
74 |
75 | switch {
76 | case strings.HasPrefix(trimmedLine, "```"):
77 | // Toggle code block state
78 | inCodeBlock = !inCodeBlock
79 | if inCodeBlock {
80 | codeLanguage = strings.TrimSpace(trimmedLine[3:])
81 | codeContent = []string{}
82 | } else {
83 | codeBlock := CodeBlock{
84 | Lang: codeLanguage,
85 | Text: strings.Join(codeContent, "\n"),
86 | }
87 | if m.Subsection != nil {
88 | m.addToSubsection(&m.Subsection, "code", codeBlock)
89 | } else if m.Section != nil {
90 | m.addToSubsection(&m.Section, "code", codeBlock)
91 | }
92 | }
93 | continue
94 |
95 | case inCodeBlock:
96 | codeContent = append(codeContent, line)
97 | continue
98 |
99 | case strings.HasPrefix(trimmedLine, "# "):
100 | if title == "" {
101 | title = strings.TrimSpace(trimmedLine[2:])
102 | } else {
103 | // Finalize the current section before starting a new one
104 | if m.Subsection != nil {
105 | if len(list) > 0 {
106 | m.addToSubsection(&m.Subsection, "lists", list)
107 | list = []string{}
108 | }
109 | if m.Section != nil {
110 | heading := (m.Subsection)["heading"].(string)
111 | m.addToSubsection(&m.Section, heading, m.Subsection)
112 | }
113 | m.Subsection = nil
114 | }
115 | if m.Section != nil {
116 | if len(list) > 0 {
117 | m.addToSubsection(&m.Section, "lists", list)
118 | list = []string{}
119 | }
120 | sections[currentHeading] = m.Section
121 | }
122 | }
123 | currentHeading = strings.TrimSpace(trimmedLine[2:])
124 | newSection := make(map[string]any)
125 | m.Section = newSection
126 | inList = false
127 | inOrderedList = false
128 |
129 | case strings.HasPrefix(trimmedLine, "##"):
130 | // New subsection heading
131 | if m.Section != nil {
132 | if len(list) > 0 {
133 | m.addToSubsection(&m.Section, "lists", list)
134 | list = []string{}
135 | }
136 | if m.Subsection != nil {
137 | if len(list) > 0 {
138 | m.addToSubsection(&m.Subsection, "lists", list)
139 | list = []string{}
140 | }
141 | heading := (m.Subsection)["heading"].(string)
142 | m.addToSubsection(&m.Section, heading, m.Subsection)
143 | }
144 | newSubsection := make(map[string]any)
145 | m.Subsection = newSubsection
146 | (m.Subsection)["heading"] = strings.TrimSpace(trimmedLine[3:])
147 | }
148 | inList = false
149 |
150 | case strings.HasPrefix(trimmedLine, "- ") || strings.HasPrefix(trimmedLine, "* "):
151 | if !inList && (m.Section != nil || m.Subsection != nil) {
152 | if len(list) > 0 {
153 | if m.Subsection != nil {
154 | m.addToSubsection(&m.Subsection, "lists", list)
155 | } else {
156 | m.addToSubsection(&m.Section, "lists", list)
157 | }
158 | list = []string{}
159 | }
160 | inList = true
161 | }
162 | if inList {
163 | list = append(list, strings.TrimSpace(trimmedLine[2:]))
164 | }
165 | continue
166 |
167 | case re.MatchString(trimmedLine):
168 | if !inOrderedList && (m.Section != nil || m.Subsection != nil) {
169 | if len(orderedList) > 0 {
170 | if m.Subsection != nil {
171 | m.addToSubsection(&m.Subsection, "ol", orderedList)
172 | } else {
173 | m.addToSubsection(&m.Section, "ol", orderedList)
174 |
175 | }
176 | orderedList = []string{}
177 | }
178 | inOrderedList = true
179 | }
180 | if inOrderedList {
181 | orderedList = append(orderedList, strings.TrimSpace(trimmedLine[3:]))
182 | }
183 | continue
184 |
185 | case strings.Contains(trimmedLine, "|") && !inCodeBlock:
186 | // skip below table header
187 | if strings.HasPrefix(trimmedLine, "|-") {
188 | continue
189 | }
190 | inTable = true
191 | cells := strings.Split(trimmedLine, "|")
192 | for i := range cells {
193 | cells[i] = strings.TrimSpace(cells[i])
194 | }
195 |
196 | if len(headers) == 0 {
197 | headers = cells[1 : len(cells)-1] // Ignore leading and trailing empty cells from split
198 | } else {
199 | if len(headers) > 0 {
200 | row := map[string]string{}
201 | for i, header := range headers {
202 | if i < len(cells) {
203 | row[header] = cells[i+1] // Skip leading empty cell
204 | }
205 | }
206 | table = append(table, row)
207 | }
208 | }
209 | inList = false
210 | inOrderedList = false
211 |
212 | case m.parseHyperlink(trimmedLine) != nil:
213 | // Hyperlink
214 | hyperlink := m.parseHyperlink(trimmedLine)
215 | if m.Subsection != nil {
216 | m.addToSubsection(&m.Subsection, "links", hyperlink)
217 | } else if m.Section != nil {
218 | m.addToSubsection(&m.Section, "links", hyperlink)
219 | }
220 | inList = false
221 | inOrderedList = false
222 |
223 | case trimmedLine != "":
224 | // Paragraph (non-empty)
225 | if m.Section != nil && !inCodeBlock && !inTable {
226 | if len(list) > 0 {
227 | if m.Subsection != nil {
228 | m.addToSubsection(&m.Subsection, "li", list)
229 | } else {
230 | m.addToSubsection(&m.Section, "li", list)
231 | }
232 | list = []string{}
233 | inList = false
234 | inOrderedList = false
235 | }
236 | if m.Subsection != nil {
237 | m.addToSubsection(&m.Subsection, "p", trimmedLine)
238 | } else {
239 | m.addToSubsection(&m.Section, "p", trimmedLine)
240 | }
241 | }
242 |
243 | case len(trimmedLine) == 0 || strings.HasPrefix(trimmedLine, "# ") || strings.HasPrefix(trimmedLine, "## "):
244 | if len(list) > 0 {
245 | if m.Subsection != nil {
246 | m.addToSubsection(&m.Subsection, "li", list)
247 | } else if m.Section != nil {
248 | m.addToSubsection(&m.Section, "li", list)
249 | }
250 | list = []string{}
251 | inList = false
252 | } else if len(orderedList) > 0 {
253 | if m.Subsection != nil {
254 | m.addToSubsection(&m.Subsection, "ol", orderedList)
255 | } else if m.Section != nil {
256 | m.addToSubsection(&m.Section, "ol", orderedList)
257 | }
258 | orderedList = []string{}
259 | inOrderedList = false
260 | }
261 |
262 | case inTable && len(trimmedLine) == 0:
263 | if len(table) > 0 {
264 | if m.Subsection != nil {
265 | m.addToSubsection(&m.Subsection, "table", table)
266 | } else if m.Section != nil {
267 | m.addToSubsection(&m.Section, "table", table)
268 | }
269 | table = nil
270 | headers = []string{}
271 | inTable = false
272 | }
273 | continue
274 | }
275 | }
276 |
277 | if m.Subsection != nil && m.Section != nil {
278 | m.addToSubsection(&m.Section, m.Subsection["heading"].(string), m.Subsection)
279 | }
280 | if m.Section != nil {
281 | sections[currentHeading] = m.Section
282 | }
283 |
284 | sections["title"] = title
285 | return sections
286 | }
287 |
288 | func (m *Codec) addToSubsection(subsection *map[string]any, key string, value any) {
289 | if subsection == nil || *subsection == nil {
290 | return
291 | }
292 | if existing, ok := (*subsection)[key].([]any); ok {
293 | (*subsection)[key] = append(existing, value)
294 | } else {
295 | (*subsection)[key] = []any{value}
296 | }
297 | }
298 |
--------------------------------------------------------------------------------
/codec/proto/proto.go:
--------------------------------------------------------------------------------
1 | package codec
2 |
3 | import (
4 | "fmt"
5 | "regexp"
6 | "strconv"
7 | "strings"
8 |
9 | "github.com/goccy/go-json"
10 | )
11 |
12 | type ProtoFile struct {
13 | PackageName string
14 | Messages map[string]Message
15 | Enums map[string]Enum
16 | }
17 |
18 | type Message struct {
19 | Name string
20 | Fields map[string]Field
21 | }
22 |
23 | type Field struct {
24 | Name string
25 | Type string
26 | Number int
27 | }
28 |
29 | type Enum struct {
30 | Name string
31 | Values map[string]int
32 | }
33 |
34 | type Codec struct{}
35 |
36 | func (c *Codec) Unmarshal(input []byte, v any) error {
37 | protoContent := string(input)
38 |
39 | protoContent = removeComments(protoContent)
40 |
41 | protoFile := &ProtoFile{Messages: make(map[string]Message), Enums: make(map[string]Enum)}
42 |
43 | messagePattern := `message\s+([A-Za-z0-9_]+)\s*\{([^}]*)\}`
44 | fieldPattern := `([A-Za-z0-9_]+)\s+([A-Za-z0-9_]+)\s*=\s*(\d+);`
45 | enumPattern := `enum\s+([A-Za-z0-9_]+)\s*\{([^}]*)\}`
46 | enumValuePattern := `([A-Za-z0-9_]+)\s*=\s*(-?\d+);`
47 |
48 | re := regexp.MustCompile(messagePattern)
49 | fieldRe := regexp.MustCompile(fieldPattern)
50 | enumRe := regexp.MustCompile(enumPattern)
51 | enumValueRe := regexp.MustCompile(enumValuePattern)
52 |
53 | packagePattern := `package\s+([A-Za-z0-9_]+);`
54 | packageRe := regexp.MustCompile(packagePattern)
55 | packageMatch := packageRe.FindStringSubmatch(protoContent)
56 | if len(packageMatch) > 0 {
57 | protoFile.PackageName = packageMatch[1]
58 | }
59 |
60 | matches := re.FindAllStringSubmatch(protoContent, -1)
61 | for _, match := range matches {
62 | messageName := match[1]
63 | messageContent := match[2]
64 |
65 | fields := make(map[string]Field)
66 | fieldMatches := fieldRe.FindAllStringSubmatch(messageContent, -1)
67 | for _, fieldMatch := range fieldMatches {
68 | fieldType := fieldMatch[1]
69 | fieldName := fieldMatch[2]
70 | fieldNumber, err := strconv.Atoi(fieldMatch[3])
71 | if err != nil {
72 | return err
73 | }
74 | fields[fieldName] = Field{
75 | Name: fieldName,
76 | Type: fieldType,
77 | Number: fieldNumber,
78 | }
79 | }
80 |
81 | protoFile.Messages[messageName] = Message{
82 | Name: messageName,
83 | Fields: fields,
84 | }
85 | }
86 |
87 | enumMatches := enumRe.FindAllStringSubmatch(protoContent, -1)
88 | for _, match := range enumMatches {
89 | enumName := match[1]
90 | enumContent := match[2]
91 |
92 | enumValues := make(map[string]int)
93 | enumValueMatches := enumValueRe.FindAllStringSubmatch(enumContent, -1)
94 | for _, enumValueMatch := range enumValueMatches {
95 | enumValueName := enumValueMatch[1]
96 | enumValueNumber := enumValueMatch[2]
97 | number, err := strconv.Atoi(enumValueNumber)
98 | if err != nil {
99 | return nil
100 | }
101 | enumValues[enumValueName] = number
102 | }
103 |
104 | protoFile.Enums[enumName] = Enum{
105 | Name: enumName,
106 | Values: enumValues,
107 | }
108 | }
109 | jsonMap, err := ConvertProtoToJSON(protoFile)
110 | if err != nil {
111 | return fmt.Errorf("error converting to JSON: %v", err)
112 | }
113 | jsonData, err := json.Marshal(jsonMap)
114 | if err != nil {
115 | return fmt.Errorf("error marshaling JSON: %v", err)
116 | }
117 | return json.Unmarshal(jsonData, v)
118 | }
119 |
120 | func removeComments(input string) string {
121 | reSingleLine := regexp.MustCompile(`//.*`)
122 | input = reSingleLine.ReplaceAllString(input, "")
123 | reMultiLine := regexp.MustCompile(`/\*.*?\*/`)
124 | input = reMultiLine.ReplaceAllString(input, "")
125 | return strings.TrimSpace(input)
126 | }
127 |
128 | func ConvertProtoToJSON(protoFile *ProtoFile) (map[string]any, error) {
129 | jsonMap := make(map[string]any)
130 | packageMap := make(map[string]any)
131 | packageMap["message"] = make(map[string]any)
132 | packageMap["enum"] = make(map[string]any)
133 |
134 | for messageName, message := range protoFile.Messages {
135 | fieldsList := []any{}
136 | for name, field := range message.Fields {
137 | values := make(map[string]any)
138 | values["name"] = name
139 | values["type"] = field.Type
140 | values["number"] = field.Number
141 | fieldsList = append(fieldsList, values)
142 | }
143 | packageMap["message"].(map[string]any)[messageName] = fieldsList
144 | }
145 |
146 | for enumName, enum := range protoFile.Enums {
147 | valuesMap := make(map[string]any)
148 | for enumValueName, enumValueNumber := range enum.Values {
149 | valuesMap[enumValueName] = enumValueNumber
150 | }
151 | packageMap["enum"].(map[string]any)[enumName] = valuesMap
152 | }
153 |
154 | jsonMap[protoFile.PackageName] = packageMap
155 |
156 | return jsonMap, nil
157 | }
158 |
--------------------------------------------------------------------------------
/codec/util/utils.go:
--------------------------------------------------------------------------------
1 | package util
2 |
3 | import (
4 | "strconv"
5 | "strings"
6 | "time"
7 | )
8 |
9 | func ParseValue(value string) any {
10 | value = strings.TrimSpace(value)
11 |
12 | if intValue, err := strconv.Atoi(value); err == nil {
13 | return intValue
14 | }
15 | if floatValue, err := strconv.ParseFloat(value, 64); err == nil {
16 | return floatValue
17 | }
18 | if boolValue, err := strconv.ParseBool(value); err == nil {
19 | return boolValue
20 | }
21 | if dateValue, err := time.Parse(time.RFC3339, value); err == nil {
22 | return dateValue
23 | }
24 | if dateValue, err := time.Parse("2006-01-02", value); err == nil {
25 | return dateValue
26 | }
27 | return value
28 | }
29 |
--------------------------------------------------------------------------------
/codec/xml/xml.go:
--------------------------------------------------------------------------------
1 | package xml
2 |
3 | import (
4 | "fmt"
5 | "github.com/JFryy/qq/codec/util"
6 | "github.com/clbanning/mxj/v2"
7 | "reflect"
8 | )
9 |
10 | type Codec struct{}
11 |
12 | func (c *Codec) Marshal(v any) ([]byte, error) {
13 | switch v := v.(type) {
14 | case map[string]any:
15 | mv := mxj.Map(v)
16 | return mv.XmlIndent("", " ")
17 | case []any:
18 | mv := mxj.Map(map[string]any{"root": v})
19 | return mv.XmlIndent("", " ")
20 | default:
21 | mv := mxj.Map(map[string]any{"value": v})
22 | return mv.XmlIndent("", " ")
23 | }
24 | }
25 |
26 | func (c *Codec) Unmarshal(input []byte, v any) error {
27 | mv, err := mxj.NewMapXml(input)
28 | if err != nil {
29 | return fmt.Errorf("error unmarshaling XML: %v", err)
30 | }
31 |
32 | parsedData := c.parseXMLValues(mv.Old())
33 |
34 | // reflection of values required for type assertions on interface
35 | rv := reflect.ValueOf(v)
36 | if rv.Kind() != reflect.Ptr || rv.IsNil() {
37 | return fmt.Errorf("provided value must be a non-nil pointer")
38 | }
39 | rv.Elem().Set(reflect.ValueOf(parsedData))
40 |
41 | return nil
42 | }
43 |
44 | // infer the type of the value and parse it accordingly
45 | func (c *Codec) parseXMLValues(v any) any {
46 | switch v := v.(type) {
47 | case map[string]any:
48 | for key, val := range v {
49 | v[key] = c.parseXMLValues(val)
50 | }
51 | return v
52 | case []any:
53 | for i, val := range v {
54 | v[i] = c.parseXMLValues(val)
55 | }
56 | return v
57 | case string:
58 | return util.ParseValue(v)
59 | default:
60 | return v
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/docs/TODO.md:
--------------------------------------------------------------------------------
1 | ## TODO
2 |
3 | * version flag update
4 | * Support for HTML
5 | * Support for excel family
6 | * TUI View fixes on large files
7 | * TUI Autocompletion improvements (back/forward/based on partial content of path rather than dorectly iterating through splatted gron like paths)
8 | * csv codec improvements: list of maps by default, more agressive heurestics for parsing.
9 | * colorizing gron
10 | * Support slurp and many other flags of jq that are useful.
11 | * Support for protobuff
12 | * more complex tests (but still keep the cli tests) with post-conversion/query value type assertions
13 | * remove external dependenices from project where applicable.
14 |
--------------------------------------------------------------------------------
/docs/demo.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/JFryy/qq/4e292a083449276775dc0c1003cba33823a7199e/docs/demo.gif
--------------------------------------------------------------------------------
/docs/qq.tape:
--------------------------------------------------------------------------------
1 | Output demo.gif
2 | Set TypingSpeed 0.125
3 | Set FontSize 30
4 |
5 | Type@10ms "# qq is a jack of all configuration formats and a master of none. But it's still pretty good. And has a lot unique features."
6 | Sleep 1
7 | Enter
8 | Type@10ms "# let's start with a simple example."
9 | Sleep 1
10 | Enter
11 | Type 'clear'
12 | Enter
13 | Sleep .25
14 | Type 'curl -Ls https://lobste.rs | qq -i html -I'
15 | Enter
16 | Sleep 1
17 | Type 'html.body.div.ol.li[].a."@href" | split("/")[3]'
18 | Sleep 1
19 | Enter
20 | Sleep 1
21 | Type@10ms "# Let's perform a demo with a Terraform module."
22 | Enter
23 | Type "qq '.module' tests/test.tf -I"
24 | Enter
25 | Sleep 1
26 | Tab
27 | Sleep .5
28 | Tab
29 | Sleep 1
30 | Enter
31 | Enter
32 | Type@10ms "# You can also output the results between included formats."
33 | Enter
34 | Sleep 1
35 | Type "qq '.module' tests/test.tf -o toml"
36 | Enter
37 | Sleep 1.5
38 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/JFryy/qq
2 |
3 | go 1.23.0
4 |
5 | toolchain go1.24.0
6 |
7 | require (
8 | github.com/BurntSushi/toml v1.5.0
9 | github.com/alecthomas/chroma v0.10.0
10 | github.com/charmbracelet/bubbles v0.21.0
11 | github.com/charmbracelet/bubbletea v1.3.4
12 | github.com/charmbracelet/lipgloss v1.1.0
13 | github.com/clbanning/mxj/v2 v2.7.0
14 | github.com/goccy/go-json v0.10.5
15 | github.com/goccy/go-yaml v1.17.1
16 | github.com/hashicorp/hcl/v2 v2.23.0
17 | github.com/itchyny/gojq v0.12.17
18 | github.com/mattn/go-isatty v0.0.20
19 | github.com/mitchellh/mapstructure v1.5.0
20 | github.com/spf13/cobra v1.9.1
21 | github.com/tmccombs/hcl2json v0.6.7
22 | github.com/zclconf/go-cty v1.16.2
23 | golang.org/x/net v0.39.0
24 | gopkg.in/ini.v1 v1.67.0
25 | )
26 |
27 | require (
28 | github.com/agext/levenshtein v1.2.3 // indirect
29 | github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect
30 | github.com/atotto/clipboard v0.1.4 // indirect
31 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
32 | github.com/charmbracelet/colorprofile v0.3.1 // indirect
33 | github.com/charmbracelet/x/ansi v0.8.0 // indirect
34 | github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
35 | github.com/charmbracelet/x/term v0.2.1 // indirect
36 | github.com/dlclark/regexp2 v1.11.5 // indirect
37 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
38 | github.com/google/go-cmp v0.7.0 // indirect
39 | github.com/inconshreveable/mousetrap v1.1.0 // indirect
40 | github.com/itchyny/timefmt-go v0.1.6 // indirect
41 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
42 | github.com/mattn/go-localereader v0.0.1 // indirect
43 | github.com/mattn/go-runewidth v0.0.16 // indirect
44 | github.com/mitchellh/go-wordwrap v1.0.1 // indirect
45 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
46 | github.com/muesli/cancelreader v0.2.2 // indirect
47 | github.com/muesli/termenv v0.16.0 // indirect
48 | github.com/rivo/uniseg v0.4.7 // indirect
49 | github.com/spf13/pflag v1.0.6 // indirect
50 | github.com/stretchr/testify v1.9.0 // indirect
51 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
52 | golang.org/x/mod v0.24.0 // indirect
53 | golang.org/x/sync v0.13.0 // indirect
54 | golang.org/x/sys v0.32.0 // indirect
55 | golang.org/x/text v0.24.0 // indirect
56 | golang.org/x/tools v0.32.0 // indirect
57 | )
58 |
--------------------------------------------------------------------------------
/go.sum:
--------------------------------------------------------------------------------
1 | github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
2 | github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
3 | github.com/agext/levenshtein v1.2.3 h1:YB2fHEn0UJagG8T1rrWknE3ZQzWM06O8AMAatNn7lmo=
4 | github.com/agext/levenshtein v1.2.3/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558=
5 | github.com/alecthomas/chroma v0.10.0 h1:7XDcGkCQopCNKjZHfYrNLraA+M7e0fMiJ/Mfikbfjek=
6 | github.com/alecthomas/chroma v0.10.0/go.mod h1:jtJATyUxlIORhUOFNA9NZDWGAQ8wpxQQqNSB4rjA/1s=
7 | github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY=
8 | github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4=
9 | github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
10 | github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
11 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
12 | github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
13 | github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
14 | github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
15 | github.com/charmbracelet/bubbletea v1.3.4 h1:kCg7B+jSCFPLYRA52SDZjr51kG/fMUEoPoZrkaDHyoI=
16 | github.com/charmbracelet/bubbletea v1.3.4/go.mod h1:dtcUCyCGEX3g9tosuYiut3MXgY/Jsv9nKVdibKKRRXo=
17 | github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40=
18 | github.com/charmbracelet/colorprofile v0.3.1/go.mod h1:/GkGusxNs8VB/RSOh3fu0TJmQ4ICMMPApIIVn0KszZ0=
19 | github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
20 | github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
21 | github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=
22 | github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q=
23 | github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k=
24 | github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
25 | github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
26 | github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
27 | github.com/clbanning/mxj/v2 v2.7.0 h1:WA/La7UGCanFe5NpHF0Q3DNtnCsVoxbPKuyBNHWRyME=
28 | github.com/clbanning/mxj/v2 v2.7.0/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s=
29 | github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
30 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
31 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
32 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
33 | github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
34 | github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
35 | github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
36 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
37 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
38 | github.com/go-test/deep v1.0.7 h1:/VSMRlnY/JSyqxQUzQLKVMAskpY/NZKFA5j2P+0pP2M=
39 | github.com/go-test/deep v1.0.7/go.mod h1:QV8Hv/iy04NyLBxAdO9njL0iVPN1S4d/A3NVv1V36o8=
40 | github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
41 | github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
42 | github.com/goccy/go-yaml v1.17.1 h1:LI34wktB2xEE3ONG/2Ar54+/HJVBriAGJ55PHls4YuY=
43 | github.com/goccy/go-yaml v1.17.1/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
44 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
45 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
46 | github.com/hashicorp/hcl/v2 v2.23.0 h1:Fphj1/gCylPxHutVSEOf2fBOh1VE4AuLV7+kbJf3qos=
47 | github.com/hashicorp/hcl/v2 v2.23.0/go.mod h1:62ZYHrXgPoX8xBnzl8QzbWq4dyDsDtfCRgIq1rbJEvA=
48 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
49 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
50 | github.com/itchyny/gojq v0.12.17 h1:8av8eGduDb5+rvEdaOO+zQUjA04MS0m3Ps8HiD+fceg=
51 | github.com/itchyny/gojq v0.12.17/go.mod h1:WBrEMkgAfAGO1LUcGOckBl5O726KPp+OlkKug0I/FEY=
52 | github.com/itchyny/timefmt-go v0.1.6 h1:ia3s54iciXDdzWzwaVKXZPbiXzxxnv1SPGFfM/myJ5Q=
53 | github.com/itchyny/timefmt-go v0.1.6/go.mod h1:RRDZYC5s9ErkjQvTvvU7keJjxUYzIISJGxm9/mAERQg=
54 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
55 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
56 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
57 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
58 | github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
59 | github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
60 | github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
61 | github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
62 | github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0=
63 | github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0=
64 | github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
65 | github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
66 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
67 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
68 | github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
69 | github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
70 | github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
71 | github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
72 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
73 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
74 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
75 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
76 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
77 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
78 | github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
79 | github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
80 | github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
81 | github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
82 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
83 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
84 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
85 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
86 | github.com/tmccombs/hcl2json v0.6.7 h1:RYKTs4kd/gzRsEiv7J3M2WQ7TYRYZVc+0H0pZdERkxA=
87 | github.com/tmccombs/hcl2json v0.6.7/go.mod h1:lJgBOOGDpbhjvdG2dLaWsqB4KBzul2HytfDTS3H465o=
88 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
89 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
90 | github.com/zclconf/go-cty v1.16.2 h1:LAJSwc3v81IRBZyUVQDUdZ7hs3SYs9jv0eZJDWHD/70=
91 | github.com/zclconf/go-cty v1.16.2/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE=
92 | github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940 h1:4r45xpDWB6ZMSMNJFMOjqrGHynW3DIBuR2H9j0ug+Mo=
93 | github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940/go.mod h1:CmBdvvj3nqzfzJ6nTCIwDTPZ56aVGvDrmztiO5g3qrM=
94 | golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
95 | golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
96 | golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
97 | golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
98 | golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
99 | golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
100 | golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
101 | golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
102 | golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
103 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
104 | golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
105 | golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
106 | golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
107 | golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
108 | golang.org/x/tools v0.32.0 h1:Q7N1vhpkQv7ybVzLFtTjvQya2ewbwNDZzUgfXGqtMWU=
109 | golang.org/x/tools v0.32.0/go.mod h1:ZxrU41P/wAbZD8EDa6dDCa6XfpkhJ7HFMjHJXfBDu8s=
110 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
111 | gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
112 | gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
113 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
114 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
115 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
116 |
--------------------------------------------------------------------------------
/internal/tui/interactive.go:
--------------------------------------------------------------------------------
1 | package tui
2 |
3 | import (
4 | "fmt"
5 | "github.com/goccy/go-json"
6 | "os"
7 | "strings"
8 |
9 | "github.com/JFryy/qq/codec"
10 | "github.com/charmbracelet/bubbles/textinput"
11 | "github.com/charmbracelet/bubbles/viewport"
12 | tea "github.com/charmbracelet/bubbletea"
13 | "github.com/charmbracelet/lipgloss"
14 | "github.com/itchyny/gojq"
15 | )
16 |
17 | var (
18 | focusedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("205"))
19 | cursorStyle = focusedStyle
20 | previewStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("178")).Italic(true)
21 | outputStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("36"))
22 | )
23 |
24 | type model struct {
25 | inputs []textinput.Model
26 | jsonInput string
27 | jqOutput string
28 | lastOutput string
29 | currentIndex int
30 | showingPreview bool
31 | jqOptions []string
32 | suggestedValue string
33 | jsonObj any
34 | viewport viewport.Model
35 | }
36 |
37 | func newModel(data string) model {
38 | m := model{
39 | inputs: make([]textinput.Model, 1),
40 | viewport: viewport.New(0, 0),
41 | }
42 |
43 | t := textinput.New()
44 | t.Cursor.Style = cursorStyle
45 | t.Placeholder = "Enter jq filter"
46 | t.SetValue(".")
47 | t.Focus()
48 | t.PromptStyle = focusedStyle
49 | t.TextStyle = focusedStyle
50 | m.inputs[0] = t
51 | m.jsonInput = string(data)
52 | m.jqOptions = generateJqOptions(m.jsonInput)
53 |
54 | m.runJqFilter()
55 | m.jsonObj, _ = jsonStrToInterface(m.jsonInput)
56 |
57 | return m
58 | }
59 |
60 | func generateJqOptions(jsonStr string) []string {
61 | var jsonData any
62 | err := json.Unmarshal([]byte(jsonStr), &jsonData)
63 | if err != nil {
64 | return []string{"."}
65 | }
66 |
67 | options := make(map[string]struct{})
68 | extractPaths(jsonData, "", options)
69 |
70 | // Convert map to slice
71 | var result []string
72 | for option := range options {
73 | result = append(result, option)
74 | }
75 | return result
76 | }
77 |
78 | func extractPaths(data any, prefix string, options map[string]struct{}) {
79 | switch v := data.(type) {
80 | case map[string]any:
81 | for key, value := range v {
82 | newPrefix := prefix + "." + key
83 | options[newPrefix] = struct{}{}
84 | extractPaths(value, newPrefix, options)
85 | }
86 | case []any:
87 | for i, item := range v {
88 | newPrefix := fmt.Sprintf("%s[%d]", prefix, i)
89 | options[newPrefix] = struct{}{}
90 | extractPaths(item, newPrefix, options)
91 | }
92 | }
93 | }
94 |
95 | func (m model) Init() tea.Cmd {
96 | return tea.EnterAltScreen
97 | }
98 |
99 | func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
100 | switch msg := msg.(type) {
101 | case tea.WindowSizeMsg:
102 | headerHeight := 2
103 | footerHeight := 1
104 | availableHeight := msg.Height - headerHeight - footerHeight
105 | m.viewport.Width = msg.Width
106 | m.viewport.Height = availableHeight
107 | m.updateViewportContent()
108 | return m, nil
109 |
110 | case tea.KeyMsg:
111 | switch msg.String() {
112 | case "ctrl+c", "esc":
113 | return m, tea.Quit
114 |
115 | // Suggest next jq option
116 | case "tab":
117 | if !m.showingPreview {
118 | m.showingPreview = true
119 | m.currentIndex = 0
120 | } else {
121 | m.currentIndex = (m.currentIndex + 1) % len(m.jqOptions)
122 | }
123 | m.suggestedValue = m.jqOptions[m.currentIndex]
124 | return m, nil
125 |
126 | case "enter":
127 | if m.showingPreview {
128 | m.inputs[0].SetValue(m.suggestedValue)
129 | m.showingPreview = false
130 | m.suggestedValue = ""
131 | m.runJqFilter()
132 | return m, nil
133 | }
134 | m.jsonObj, _ = jsonStrToInterface(m.jsonInput)
135 | return m, tea.Quit
136 |
137 | case "up":
138 | m.viewport.LineUp(1)
139 | return m, nil
140 |
141 | case "down":
142 | m.viewport.LineDown(1)
143 | return m, nil
144 |
145 | case "pageup":
146 | m.viewport.ViewUp()
147 | return m, nil
148 | case "pagedown":
149 | m.viewport.ViewDown()
150 | return m, nil
151 |
152 | default:
153 | if m.showingPreview {
154 | m.showingPreview = false
155 | m.suggestedValue = ""
156 | return m, nil
157 | }
158 | }
159 | }
160 |
161 | // Handle character input and blinking
162 | cmd := m.updateInputs(msg)
163 |
164 | // Evaluate jq filter on input change
165 | m.runJqFilter()
166 |
167 | return m, cmd
168 | }
169 |
170 | func (m *model) updateInputs(msg tea.Msg) tea.Cmd {
171 | cmds := make([]tea.Cmd, len(m.inputs))
172 | for i := range m.inputs {
173 | m.inputs[i], cmds[i] = m.inputs[i].Update(msg)
174 | }
175 |
176 | return tea.Batch(cmds...)
177 | }
178 |
179 | func jsonStrToInterface(jsonStr string) (any, error) {
180 | var jsonData any
181 | err := json.Unmarshal([]byte(jsonStr), &jsonData)
182 | if err != nil {
183 | return nil, fmt.Errorf("Invalid JSON input: %s", err)
184 | }
185 | return jsonData, nil
186 | }
187 |
188 | func (m *model) runJqFilter() {
189 | query, err := gojq.Parse(m.inputs[0].Value())
190 | if err != nil {
191 | m.jqOutput = fmt.Sprintf("Invalid jq query: %s\n\nLast valid output:\n%s", err, m.lastOutput)
192 | m.updateViewportContent()
193 | return
194 | }
195 |
196 | var jsonData any
197 | err = json.Unmarshal([]byte(m.jsonInput), &jsonData)
198 | if err != nil {
199 | m.jqOutput = fmt.Sprintf("Invalid JSON input: %s\n\nLast valid output:\n%s", err, m.lastOutput)
200 | m.updateViewportContent()
201 | return
202 | }
203 |
204 | iter := query.Run(jsonData)
205 | var result []string
206 | isNull := true
207 | for {
208 | v, ok := iter.Next()
209 | if !ok {
210 | break
211 | }
212 | if err, ok := v.(error); ok {
213 | m.jqOutput = fmt.Sprintf("Error executing jq query: %s\n\nLast valid output:\n%s", err, m.lastOutput)
214 | m.updateViewportContent()
215 | return
216 | }
217 | output, err := json.MarshalIndent(v, "", " ")
218 | if err != nil {
219 | m.jqOutput = fmt.Sprintf("Error formatting output: %s\n\nLast valid output:\n%s", err, m.lastOutput)
220 | m.updateViewportContent()
221 | return
222 | }
223 | if string(output) != "null" {
224 | isNull = false
225 | result = append(result, string(output))
226 | }
227 | }
228 |
229 | if isNull {
230 | m.jqOutput = fmt.Sprintf("Query result is null\n\nLast valid output:\n%s", m.lastOutput)
231 | m.updateViewportContent()
232 | return
233 | }
234 |
235 | m.jqOutput = strings.Join(result, "\n")
236 | m.lastOutput = m.jqOutput
237 | m.updateViewportContent()
238 | }
239 |
240 | func (m *model) updateViewportContent() {
241 | prettyOutput, err := codec.PrettyFormat(m.jqOutput, codec.JSON, false)
242 | if err != nil {
243 | m.viewport.SetContent(fmt.Sprintf("Error formatting output: %s", err))
244 | return
245 | }
246 | m.viewport.SetContent(outputStyle.Render(prettyOutput))
247 | }
248 |
249 | func (m model) View() string {
250 | var b strings.Builder
251 |
252 | for i := range m.inputs {
253 | if m.showingPreview && m.suggestedValue != "" {
254 | b.WriteString(m.inputs[i].View() + previewStyle.Render(m.suggestedValue))
255 | } else {
256 | b.WriteString(m.inputs[i].View())
257 | }
258 | if i < len(m.inputs)-1 {
259 | b.WriteRune('\n')
260 | }
261 | }
262 |
263 | b.WriteString("\n")
264 | b.WriteString(m.viewport.View())
265 |
266 | return b.String()
267 | }
268 |
269 | func printOutput(m model) {
270 | s := m.inputs[0].Value()
271 | fmt.Printf("\033[32m%s\033[0m\n", s)
272 | o, err := codec.PrettyFormat(m.jqOutput, codec.JSON, false)
273 | if err != nil {
274 | fmt.Println("Error formatting output:", err)
275 | os.Exit(1)
276 | }
277 | fmt.Println(o)
278 | os.Exit(0)
279 | }
280 |
281 | func Interact(s string) {
282 | m, err := tea.NewProgram(newModel(s), tea.WithAltScreen()).Run()
283 | if err != nil {
284 | fmt.Println("Error running program:", err)
285 | os.Exit(1)
286 | }
287 | printOutput(m.(model))
288 | }
289 |
--------------------------------------------------------------------------------
/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "fmt"
5 | "github.com/JFryy/qq/cli"
6 | "github.com/JFryy/qq/codec"
7 | "os"
8 | )
9 |
10 | func main() {
11 | _ = codec.SupportedFileTypes
12 | rootCmd := cli.CreateRootCmd()
13 | if err := rootCmd.Execute(); err != nil {
14 | fmt.Println(err)
15 | os.Exit(1)
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/tests/example.proto:
--------------------------------------------------------------------------------
1 | syntax = "proto3";
2 | package company;
3 |
4 | enum Status {
5 | ACTIVE = 0;
6 | INACTIVE = 1;
7 | RETIRED = 2;
8 | }
9 |
10 | message Address {
11 | string street = 1;
12 | string city = 2;
13 | }
14 |
15 | message Employee {
16 | string first_name = 1;
17 | string last_name = 2;
18 | int32 employee_id = 3;
19 | Status status = 4;
20 | string email = 5;
21 | optional string phone_number = 6;
22 | reserved 7, 8;
23 | string department_name = 9;
24 | bool is_manager = 10;
25 | }
26 |
27 | message Department {
28 | string name = 1;
29 | repeated Employee employees = 2;
30 | }
31 |
32 | message Project {
33 | string name = 1;
34 | string description = 2;
35 | repeated Employee team_members = 3;
36 | }
37 |
38 | message Company {
39 | string name = 1;
40 | repeated Department departments = 2;
41 | reserved 3 to 5;
42 | }
43 |
44 |
--------------------------------------------------------------------------------
/tests/test.csv:
--------------------------------------------------------------------------------
1 | ID, Date, Temperature, Humidity, Location, Status, Description
2 | 1, 2024-07-01, 23.5, 55, Warehouse, true, Storage check completed
3 | 2, 2024-07-02, 24.7, 52, Warehouse, false, Equipment maintenance pending
4 | 3, 2024-07-03, 22.8, 60, Warehouse, true, All systems operational
5 | 4, 2024-07-04, 23.1, 58, Laboratory, true, Experiment started
6 | 5, 2024-07-05, 25.0, 50, Laboratory, false, Data collection in progress
7 | 6, 2024-07-06, 21.5, 65, Laboratory, true, Analysis completed
8 | 7, 2024-07-07, 20.3, 70, Greenhouse, true, Irrigation system activated
9 | 8, 2024-07-08, 22.4, 63, Greenhouse, false, Pest control required
10 |
--------------------------------------------------------------------------------
/tests/test.gron:
--------------------------------------------------------------------------------
1 | example.name = "John";
2 | example.age = 30;
3 | example.address.city = "New York";
4 | example.address.zip_code = "10001";
5 | example.skills[0] = "Go";
6 | example.skills[1] = "JavaScript";
7 | example.contacts.email = "john@example.com";
8 | example.contacts.phone = "+1234567890";
9 |
--------------------------------------------------------------------------------
/tests/test.hcl:
--------------------------------------------------------------------------------
1 | app_name = "SimpleApp"
2 | version = "1.0.0"
3 |
4 | database {
5 | host = "localhost"
6 | port = 5432
7 | username = "admin"
8 | password = "password"
9 | }
10 |
11 | features {
12 | enable_feature_x = true
13 | enable_feature_y = false
14 | }
15 |
16 |
--------------------------------------------------------------------------------
/tests/test.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Example Form
7 |
39 |
40 |
41 | Example Form
42 |
85 |
86 |
87 |
88 |
--------------------------------------------------------------------------------
/tests/test.ini:
--------------------------------------------------------------------------------
1 | [general]
2 | app_name = TestApp
3 | version = 1.0.0
4 |
5 | [database]
6 | host = localhost
7 | port = 5432
8 | username = admin
9 | password = secret
10 |
11 | [features]
12 | enable_feature_x = true
13 | enable_feature_y = false
14 |
15 |
--------------------------------------------------------------------------------
/tests/test.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "John Doe",
3 | "age": 30,
4 | "email": "john.doe@example.com",
5 | "address": {
6 | "street": "123 Main St",
7 | "city": "Anytown",
8 | "state": "CA",
9 | "zipcode": "12345"
10 | },
11 | "phone": {
12 | "home": "555-1234",
13 | "work": "555-5678"
14 | },
15 | "children": [
16 | {
17 | "name": "Alice",
18 | "age": 5
19 | },
20 | {
21 | "name": "Bob",
22 | "age": 8
23 | }
24 | ],
25 | "tags": ["tag1", "tag2", "tag3"],
26 | "active": true
27 | }
28 |
29 |
--------------------------------------------------------------------------------
/tests/test.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | set -e
4 |
5 | if [ -z "$(which jq)" ]; then
6 | echo "jq is not installed. Please install jq."
7 | exit 1
8 | fi
9 |
10 | print() {
11 | case $1 in
12 | red)
13 | echo -e "\033[0;31m$2\033[0m"
14 | ;;
15 | green)
16 | echo -e "\033[0;32m$2\033[0m"
17 | ;;
18 | yellow)
19 | echo -e "\033[0;33m$2\033[0m"
20 | ;;
21 | *)
22 | echo -e "\033[0;33m$2\033[0m"
23 | ;;
24 | esac
25 | }
26 |
27 | extensions=$(ls -1 tests/* | grep -Ev '.sh|ini')
28 | for i in ${extensions}; do
29 | echo "Testing $i"
30 | input=$(echo $i | cut -d. -f2)
31 |
32 | for f in ${extensions}; do
33 | extension=$(echo $f | cut -d. -f2)
34 |
35 | if [[ "$input" == "csv" && "$extension" != "csv" ]]
36 | then
37 | print "yellow" "Skipping unsupported conversion from CSV to non-CSV compatible structure"
38 | continue
39 | fi
40 |
41 | if [[ "$input" != csv && $extension == "csv" ]]
42 | then
43 | print "yellow" "Skipping unsupported conversion from CSV to non-CSV compatible structure"
44 | continue
45 | fi
46 |
47 | print "" "============================================"
48 | print "" "Executing: cat $i | grep -v '#' | bin/qq -i $input -o $extension"
49 | print "" "============================================"
50 | cat "$i" | grep -v "#" | bin/qq -i "$input" -o "$extension"
51 | print "green" "============================================"
52 | print "green" "Success."
53 | print "green" "============================================"
54 | done
55 |
56 | test_cases=$(cat $i | grep "#" | cut -d# -f2)
57 | for case in ${test_cases}; do
58 | print "" "============================================"
59 | print "yellow" "Testing case: qq $case $i"
60 | print "" "============================================"
61 | cat "$i" | grep -v \# | bin/qq "${case}" "$i"
62 | done
63 | done
64 |
65 | previous_ext="json"
66 | for file in ${extensions}; do
67 | if [[ $(echo -n $file | grep csv) ]]
68 | then
69 | continue
70 | fi
71 | print "" $file
72 | print "" "============================================"
73 | print "" "Executing: cat $file | jq . | bin/qq -o $previous_ext"
74 | print "" "============================================"
75 | bin/qq "$file" | jq . | bin/qq -o "$previous_ext"
76 | print "green" "============================================"
77 | print "green" "Success."
78 | print "green" "============================================"
79 | previous_ext=$(echo "$file" | cut -d. -f2)
80 | done
81 |
82 |
--------------------------------------------------------------------------------
/tests/test.tf:
--------------------------------------------------------------------------------
1 | output "instance_id" {
2 | description = "The ID of the AWS EC2 instance"
3 | value = aws_instance.example.id
4 | }
5 |
6 | output "instance_public_ip" {
7 | description = "The public IP address of the AWS EC2 instance"
8 | value = aws_instance.example.public_ip
9 | }
10 |
11 |
12 | module "vpc" {
13 | source = "terraform-aws-modules/vpc/aws"
14 | version = "3.0.0"
15 |
16 | name = "my-vpc"
17 | cidr = "10.0.0.0/16"
18 |
19 | azs = ["us-west-2a", "us-west-2b"]
20 | private_subnets = ["10.0.1.0/24", "10.0.2.0/24"]
21 | public_subnets = ["10.0.101.0/24", "10.0.102.0/24"]
22 |
23 | enable_nat_gateway = true
24 | single_nat_gateway = true
25 |
26 | tags = {
27 | Terraform = "true"
28 | Environment = "dev"
29 | }
30 | }
31 |
32 | data "aws_ami" "latest_amazon_linux" {
33 | most_recent = true
34 | owners = ["amazon"]
35 |
36 | filter {
37 | name = "name"
38 | values = ["amzn2-ami-hvm-*-x86_64-gp2"]
39 | }
40 | }
41 |
42 | resource "aws_instance" "example" {
43 | ami = data.aws_ami.latest_amazon_linux.id
44 | instance_type = var.instance_type
45 |
46 | tags = {
47 | Name = "ExampleInstance"
48 | }
49 | }
50 |
51 |
--------------------------------------------------------------------------------
/tests/test.toml:
--------------------------------------------------------------------------------
1 | title = "TOML Example"
2 |
3 | [owner]
4 | name = "Tom Preston-Werner"
5 | dob = 1979-05-27T07:32:00Z
6 |
7 | [database]
8 | server = "192.168.1.1"
9 | port = 5432
10 | connection_max = 5000
11 | enabled = true
12 |
13 | [servers]
14 | [servers.alpha]
15 | ip = "10.0.0.1"
16 | dc = "eqdc10"
17 |
18 | [servers.beta]
19 | ip = "10.0.0.2"
20 | dc = "eqdc20"
21 |
22 | [clients]
23 | data = [ ["gamma", "delta"], [1, 2] ]
24 |
25 | [clients.inline]
26 | name = "example"
27 | age = 25
28 |
29 |
--------------------------------------------------------------------------------
/tests/test.txt:
--------------------------------------------------------------------------------
1 | this is an example
2 | this is also another one
3 | this is one more
4 | 42
5 | 42.1
6 | false
7 |
8 |
--------------------------------------------------------------------------------
/tests/test.xml:
--------------------------------------------------------------------------------
1 | # .example.person[]
2 |
3 |
4 | John Doe
5 | 30
6 | 123 Main St
7 |
8 |
9 | Jane Doe
10 | 30
11 | 123 Main St
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/tests/test.yaml:
--------------------------------------------------------------------------------
1 | test:
2 | example: [a,b,c,d]
3 | thing:
4 | example: 42
5 |
--------------------------------------------------------------------------------