├── .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 | ![Demo GIF](docs/demo.gif) 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 |
43 |
44 | 45 | 46 |
47 |
48 | 49 | 50 |
51 |
52 | 53 | 54 |
55 |
56 | 57 | 58 |
59 |
60 | 61 | 62 | 63 | 64 | 65 |
66 |
67 | 68 | 74 |
75 |
76 | 77 | 78 |
79 |
80 | 81 | 82 |
83 | 84 |
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 | --------------------------------------------------------------------------------