├── .gitignore
├── tests
├── test.env
├── test.msgpack
├── test.parquet
├── test.txt
├── test.ini
├── test.yaml
├── test.hcl
├── test.gron
├── test.xml
├── test.csv
├── test.toml
├── test.json
├── example.proto
├── test.tf
├── test.html
└── test.sh
├── docs
├── demo.gif
├── TODO.md
└── qq.tape
├── Dockerfile
├── main.go
├── codec
├── msgpack
│ ├── msgpack.go
│ └── msgpack_test.go
├── json
│ ├── json.go
│ └── json_test.go
├── util
│ └── utils.go
├── yaml
│ ├── yaml.go
│ └── yaml_test.go
├── line
│ └── line.go
├── xml
│ ├── xml.go
│ └── xml_test.go
├── ini
│ └── ini_codec.go
├── hcl
│ └── hcl.go
├── csv
│ ├── csv.go
│ └── csv_test.go
├── codec_test.go
├── html
│ └── html.go
├── gron
│ └── gron.go
├── proto
│ └── proto.go
├── env
│ ├── env.go
│ └── env_test.go
├── parquet
│ ├── parquet.go
│ └── parquet_test.go
└── codec.go
├── Makefile
├── .github
├── workflows
│ ├── go.yml
│ ├── docker-image.yml
│ └── build.yml
└── PULL_REQUEST_TEMPLATE.md
├── LICENSE
├── go.mod
├── cli
└── qq.go
├── README.md
├── internal
└── tui
│ └── interactive.go
└── go.sum
/.gitignore:
--------------------------------------------------------------------------------
1 | bin/
2 | .DS_Store
3 |
--------------------------------------------------------------------------------
/tests/test.env:
--------------------------------------------------------------------------------
1 | subnet_id=subnet-12345678
2 | vpc_id=vpc-12345678
3 |
--------------------------------------------------------------------------------
/docs/demo.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/JFryy/qq/HEAD/docs/demo.gif
--------------------------------------------------------------------------------
/tests/test.msgpack:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/JFryy/qq/HEAD/tests/test.msgpack
--------------------------------------------------------------------------------
/tests/test.parquet:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/JFryy/qq/HEAD/tests/test.parquet
--------------------------------------------------------------------------------
/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.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.yaml:
--------------------------------------------------------------------------------
1 | # .test.thing.example
2 | # keys
3 | # '.test.example | map(. + "_processed") | reverse'
4 | # '.test | to_entries | map(.key)'
5 | # 'keys | length'
6 | # '.test.example | sort'
7 | test:
8 | example: [a,b,c,d]
9 | thing:
10 | example: 42
11 |
--------------------------------------------------------------------------------
/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.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.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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/codec/msgpack/msgpack.go:
--------------------------------------------------------------------------------
1 | package msgpack
2 |
3 | import (
4 | "github.com/vmihailenco/msgpack/v5"
5 | )
6 |
7 | type Codec struct{}
8 |
9 | func (c *Codec) Unmarshal(data []byte, v any) error {
10 | return msgpack.Unmarshal(data, v)
11 | }
12 |
13 | func (c *Codec) Marshal(v any) ([]byte, error) {
14 | return msgpack.Marshal(v)
15 | }
16 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/yaml/yaml.go:
--------------------------------------------------------------------------------
1 | package yaml
2 |
3 | import (
4 | "github.com/goccy/go-yaml"
5 | "strings"
6 | )
7 |
8 | type Codec struct{}
9 |
10 | // Unmarshal strips document delimiter and unmarshals YAML
11 | func (c Codec) Unmarshal(data []byte, v any) error {
12 | // Strip document delimiter if present at the beginning
13 | content := strings.TrimSpace(string(data))
14 | if strings.HasPrefix(content, "---") {
15 | lines := strings.SplitN(content, "\n", 2)
16 | if len(lines) > 1 {
17 | content = lines[1]
18 | } else {
19 | content = ""
20 | }
21 | }
22 | return yaml.Unmarshal([]byte(content), v)
23 | }
24 |
25 | func (c Codec) Marshal(v any) ([]byte, error) {
26 | return yaml.Marshal(v)
27 | }
28 |
--------------------------------------------------------------------------------
/tests/test.toml:
--------------------------------------------------------------------------------
1 | # .owner.name
2 | # .database.port
3 | # .servers.alpha.ip
4 | # '.servers | to_entries | map(.key)'
5 | # '.database | keys | sort'
6 | # '[.database.port, .database.connection_max] | add'
7 | # '.clients.data | flatten | sort'
8 | # '.database.enabled | if . then "ONLINE" else "OFFLINE" end'
9 | title = "TOML Example"
10 |
11 | [owner]
12 | name = "Tom Preston-Werner"
13 | dob = 1979-05-27T07:32:00Z
14 |
15 | [database]
16 | server = "192.168.1.1"
17 | port = 5432
18 | connection_max = 5000
19 | enabled = true
20 |
21 | [servers]
22 | [servers.alpha]
23 | ip = "10.0.0.1"
24 | dc = "eqdc10"
25 |
26 | [servers.beta]
27 | ip = "10.0.0.2"
28 | dc = "eqdc20"
29 |
30 | [clients]
31 | data = [ ["gamma", "delta"], [1, 2] ]
32 |
33 | [clients.inline]
34 | name = "example"
35 | age = 25
36 |
37 |
--------------------------------------------------------------------------------
/tests/test.json:
--------------------------------------------------------------------------------
1 | # .name
2 | # .address.city
3 | # '.children | map(.name) | join(", ")'
4 | # '.phone | to_entries | map("\(.key): \(.value)") | join(" | ")'
5 | # '{full_name: .name, location: .address.city, child_count: (.children | length)}'
6 | # 'if .active then "ACTIVE_USER" else "INACTIVE_USER" end'
7 | {
8 | "name": "John Doe",
9 | "age": 30,
10 | "email": "john.doe@example.com",
11 | "address": {
12 | "street": "123 Main St",
13 | "city": "Anytown",
14 | "state": "CA",
15 | "zipcode": "12345"
16 | },
17 | "phone": {
18 | "home": "555-1234",
19 | "work": "555-5678"
20 | },
21 | "children": [
22 | {
23 | "name": "Alice",
24 | "age": 5
25 | },
26 | {
27 | "name": "Bob",
28 | "age": 8
29 | }
30 | ],
31 | "tags": ["tag1", "tag2", "tag3"],
32 | "active": true
33 | }
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/.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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 | defaultSection := cfg.Section("")
42 |
43 | for section, sectionValue := range data {
44 | sectionMap, ok := sectionValue.(map[string]any)
45 | if !ok {
46 | // Handle scalar values by putting them in the default section
47 | var valueStr string
48 | if sectionValue == nil {
49 | valueStr = ""
50 | } else {
51 | valueStr = fmt.Sprintf("%v", sectionValue)
52 | }
53 | _, err := defaultSection.NewKey(section, valueStr)
54 | if err != nil {
55 | return nil, err
56 | }
57 | continue
58 | }
59 |
60 | sec, err := cfg.NewSection(section)
61 | if err != nil {
62 | return nil, err
63 | }
64 |
65 | for key, value := range sectionMap {
66 | var valueStr string
67 | if value == nil {
68 | valueStr = ""
69 | } else {
70 | valueStr = fmt.Sprintf("%v", value)
71 | }
72 | _, err := sec.NewKey(key, valueStr)
73 | if err != nil {
74 | return nil, err
75 | }
76 | }
77 | }
78 |
79 | var b strings.Builder
80 | _, err := cfg.WriteTo(&b)
81 | if err != nil {
82 | return nil, fmt.Errorf("error writing INI data: %v", err)
83 | }
84 | return []byte(b.String()), nil
85 | }
86 |
--------------------------------------------------------------------------------
/tests/test.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Example Form
7 |
39 |
40 |
41 | Example Form
42 |
85 |
86 |
87 |
88 |
--------------------------------------------------------------------------------
/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/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/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 |
--------------------------------------------------------------------------------
/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/apache/arrow/go/v16 v16.1.0
11 | github.com/charmbracelet/bubbles v0.21.0
12 | github.com/charmbracelet/bubbletea v1.3.5
13 | github.com/charmbracelet/lipgloss v1.1.0
14 | github.com/clbanning/mxj/v2 v2.7.0
15 | github.com/goccy/go-json v0.10.5
16 | github.com/goccy/go-yaml v1.18.0
17 | github.com/hashicorp/hcl/v2 v2.23.0
18 | github.com/itchyny/gojq v0.12.17
19 | github.com/mattn/go-isatty v0.0.20
20 | github.com/mitchellh/mapstructure v1.5.0
21 | github.com/spf13/cobra v1.9.1
22 | github.com/tmccombs/hcl2json v0.6.7
23 | github.com/zclconf/go-cty v1.16.3
24 | golang.org/x/net v0.41.0
25 | gopkg.in/ini.v1 v1.67.0
26 | )
27 |
28 | require (
29 | github.com/JohnCGriffin/overflow v0.0.0-20211019200055-46fa312c352c // indirect
30 | github.com/agext/levenshtein v1.2.3 // indirect
31 | github.com/andybalholm/brotli v1.1.0 // indirect
32 | github.com/apache/thrift v0.19.0 // indirect
33 | github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect
34 | github.com/atotto/clipboard v0.1.4 // indirect
35 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
36 | github.com/charmbracelet/colorprofile v0.3.1 // indirect
37 | github.com/charmbracelet/x/ansi v0.9.3 // indirect
38 | github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
39 | github.com/charmbracelet/x/term v0.2.1 // indirect
40 | github.com/dlclark/regexp2 v1.11.5 // indirect
41 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
42 | github.com/golang/protobuf v1.5.3 // indirect
43 | github.com/golang/snappy v0.0.4 // indirect
44 | github.com/google/flatbuffers v24.3.25+incompatible // indirect
45 | github.com/google/go-cmp v0.7.0 // indirect
46 | github.com/inconshreveable/mousetrap v1.1.0 // indirect
47 | github.com/itchyny/timefmt-go v0.1.6 // indirect
48 | github.com/klauspost/asmfmt v1.3.2 // indirect
49 | github.com/klauspost/compress v1.17.7 // indirect
50 | github.com/klauspost/cpuid/v2 v2.2.7 // indirect
51 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
52 | github.com/mattn/go-localereader v0.0.1 // indirect
53 | github.com/mattn/go-runewidth v0.0.16 // indirect
54 | github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8 // indirect
55 | github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3 // indirect
56 | github.com/mitchellh/go-wordwrap v1.0.1 // indirect
57 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
58 | github.com/muesli/cancelreader v0.2.2 // indirect
59 | github.com/muesli/termenv v0.16.0 // indirect
60 | github.com/pierrec/lz4/v4 v4.1.21 // indirect
61 | github.com/rivo/uniseg v0.4.7 // indirect
62 | github.com/spf13/pflag v1.0.6 // indirect
63 | github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect
64 | github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
65 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
66 | github.com/zeebo/xxh3 v1.0.2 // indirect
67 | golang.org/x/exp v0.0.0-20240222234643-814bf88cf225 // indirect
68 | golang.org/x/mod v0.25.0 // indirect
69 | golang.org/x/sync v0.15.0 // indirect
70 | golang.org/x/sys v0.33.0 // indirect
71 | golang.org/x/text v0.26.0 // indirect
72 | golang.org/x/tools v0.34.0 // indirect
73 | golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect
74 | google.golang.org/genproto/googleapis/rpc v0.0.0-20240123012728-ef4313101c80 // indirect
75 | google.golang.org/grpc v1.62.1 // indirect
76 | google.golang.org/protobuf v1.33.0 // indirect
77 | )
78 |
--------------------------------------------------------------------------------
/codec/msgpack/msgpack_test.go:
--------------------------------------------------------------------------------
1 | package msgpack
2 |
3 | import (
4 | "testing"
5 | )
6 |
7 | func TestCodec_Marshal(t *testing.T) {
8 | codec := &Codec{}
9 |
10 | tests := []struct {
11 | name string
12 | input interface{}
13 | wantErr bool
14 | }{
15 | {
16 | name: "simple map",
17 | input: map[string]interface{}{
18 | "name": "test",
19 | "age": 30,
20 | },
21 | wantErr: false,
22 | },
23 | {
24 | name: "array",
25 | input: []interface{}{
26 | "item1",
27 | "item2",
28 | 42,
29 | },
30 | wantErr: false,
31 | },
32 | {
33 | name: "nil",
34 | input: nil,
35 | wantErr: false,
36 | },
37 | }
38 |
39 | for _, tt := range tests {
40 | t.Run(tt.name, func(t *testing.T) {
41 | got, err := codec.Marshal(tt.input)
42 | if (err != nil) != tt.wantErr {
43 | t.Errorf("Codec.Marshal() error = %v, wantErr %v", err, tt.wantErr)
44 | return
45 | }
46 | if !tt.wantErr && got == nil {
47 | t.Errorf("Codec.Marshal() = nil, want non-nil")
48 | }
49 | })
50 | }
51 | }
52 |
53 | func TestCodec_Unmarshal(t *testing.T) {
54 | codec := &Codec{}
55 |
56 | // Test data
57 | testData := map[string]interface{}{
58 | "name": "test",
59 | "age": 30,
60 | "active": true,
61 | }
62 |
63 | // Marshal first
64 | marshaled, err := codec.Marshal(testData)
65 | if err != nil {
66 | t.Fatalf("Failed to marshal test data: %v", err)
67 | }
68 |
69 | // Test unmarshal
70 | var result interface{}
71 | err = codec.Unmarshal(marshaled, &result)
72 | if err != nil {
73 | t.Errorf("Codec.Unmarshal() error = %v", err)
74 | return
75 | }
76 |
77 | // Convert to map for comparison
78 | resultMap, ok := result.(map[string]interface{})
79 | if !ok {
80 | t.Errorf("Codec.Unmarshal() result is not a map")
81 | return
82 | }
83 |
84 | // Check specific values
85 | if resultMap["name"] != "test" {
86 | t.Errorf("Codec.Unmarshal() name = %v, want %v", resultMap["name"], "test")
87 | }
88 |
89 | // Age might be converted to different numeric type, so check as number
90 | if age, ok := resultMap["age"].(int8); ok {
91 | if int(age) != 30 {
92 | t.Errorf("Codec.Unmarshal() age = %v, want %v", age, 30)
93 | }
94 | } else if age, ok := resultMap["age"].(int); ok {
95 | if age != 30 {
96 | t.Errorf("Codec.Unmarshal() age = %v, want %v", age, 30)
97 | }
98 | } else {
99 | t.Errorf("Codec.Unmarshal() age type = %T, want int", resultMap["age"])
100 | }
101 |
102 | if resultMap["active"] != true {
103 | t.Errorf("Codec.Unmarshal() active = %v, want %v", resultMap["active"], true)
104 | }
105 | }
106 |
107 | func TestCodec_RoundTrip(t *testing.T) {
108 | codec := &Codec{}
109 |
110 | tests := []struct {
111 | name string
112 | input interface{}
113 | }{
114 | {
115 | name: "complex map",
116 | input: map[string]interface{}{
117 | "string": "hello",
118 | "number": 42,
119 | "float": 3.14,
120 | "bool": true,
121 | "array": []interface{}{"a", "b", "c"},
122 | "nested": map[string]interface{}{
123 | "key": "value",
124 | },
125 | },
126 | },
127 | {
128 | name: "array",
129 | input: []interface{}{1, 2, 3, "test", true},
130 | },
131 | {
132 | name: "string",
133 | input: "simple string",
134 | },
135 | {
136 | name: "number",
137 | input: 123,
138 | },
139 | {
140 | name: "boolean",
141 | input: true,
142 | },
143 | }
144 |
145 | for _, tt := range tests {
146 | t.Run(tt.name, func(t *testing.T) {
147 | // Marshal
148 | marshaled, err := codec.Marshal(tt.input)
149 | if err != nil {
150 | t.Fatalf("Marshal failed: %v", err)
151 | }
152 |
153 | // Unmarshal
154 | var result interface{}
155 | err = codec.Unmarshal(marshaled, &result)
156 | if err != nil {
157 | t.Fatalf("Unmarshal failed: %v", err)
158 | }
159 |
160 | // For complex types, we mainly check that unmarshaling doesn't fail
161 | // MessagePack may alter numeric types during round-trip
162 | if result == nil && tt.input != nil {
163 | t.Errorf("Round trip failed: got nil, want non-nil")
164 | }
165 | })
166 | }
167 | }
168 |
--------------------------------------------------------------------------------
/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/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/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/env/env.go:
--------------------------------------------------------------------------------
1 | package env
2 |
3 | import (
4 | "encoding/json"
5 | "errors"
6 | "fmt"
7 | "regexp"
8 | "strings"
9 | )
10 |
11 | // Codec handles environment file parsing and marshaling
12 | type Codec struct{}
13 |
14 | // Unmarshal parses environment file data into the provided interface
15 | func (c *Codec) Unmarshal(data []byte, v interface{}) error {
16 | if v == nil {
17 | return errors.New("v cannot be nil")
18 | }
19 |
20 | result, err := c.Parse(string(data))
21 | if err != nil {
22 | return err
23 | }
24 |
25 | jsonData, err := json.Marshal(result)
26 | if err != nil {
27 | return err
28 | }
29 |
30 | return json.Unmarshal(jsonData, v)
31 | }
32 |
33 | // Marshal converts data back to environment file format
34 | func (c *Codec) Marshal(v interface{}) ([]byte, error) {
35 | // Convert to our expected format first
36 | data, err := json.Marshal(v)
37 | if err != nil {
38 | return nil, err
39 | }
40 |
41 | var envVars map[string]string
42 | if err := json.Unmarshal(data, &envVars); err != nil {
43 | return nil, errors.New("env format only supports simple key-value pairs, cannot convert complex nested structures")
44 | }
45 |
46 | var lines []string
47 | for key, value := range envVars {
48 | lines = append(lines, fmt.Sprintf("%s=%s", key, c.formatValue(value)))
49 | }
50 |
51 | return []byte(strings.Join(lines, "\n")), nil
52 | }
53 |
54 | // Parse processes environment file content into simple key-value pairs
55 | func (c *Codec) Parse(content string) (map[string]string, error) {
56 | result := make(map[string]string)
57 | lines := strings.Split(content, "\n")
58 |
59 | // Pattern for parsing variable assignments
60 | varPattern := regexp.MustCompile(`^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$`)
61 |
62 | for _, line := range lines {
63 | line = strings.TrimSpace(line)
64 |
65 | // Skip empty lines and comments
66 | if line == "" || strings.HasPrefix(line, "#") {
67 | continue
68 | }
69 |
70 | // Parse variable assignment
71 | if matches := varPattern.FindStringSubmatch(line); matches != nil {
72 | key := matches[1]
73 | valueWithComment := matches[2]
74 |
75 | // Extract just the value, ignoring comments
76 | value := c.extractValue(valueWithComment)
77 | result[key] = value
78 | }
79 | }
80 |
81 | return result, nil
82 | }
83 |
84 | // extractValue extracts the value part from a line, handling quotes and ignoring comments
85 | func (c *Codec) extractValue(input string) string {
86 | input = strings.TrimSpace(input)
87 |
88 | // Handle quoted strings first
89 | if strings.HasPrefix(input, `"`) {
90 | // Find the closing quote, handling escaped quotes
91 | escaped := false
92 | for i := 1; i < len(input); i++ {
93 | if escaped {
94 | escaped = false
95 | continue
96 | }
97 | if input[i] == '\\' {
98 | escaped = true
99 | continue
100 | }
101 | if input[i] == '"' {
102 | // Found end quote, remove outer quotes and handle escapes
103 | unquoted := input[1:i]
104 | // Handle specific escape sequences only
105 | // Use a more careful approach to avoid double-processing
106 | result := ""
107 | for j := 0; j < len(unquoted); j++ {
108 | if j < len(unquoted)-1 && unquoted[j] == '\\' {
109 | switch unquoted[j+1] {
110 | case '"':
111 | result += `"`
112 | j++ // skip next char
113 | case 'n':
114 | result += "\n"
115 | j++ // skip next char
116 | case 't':
117 | result += "\t"
118 | j++ // skip next char
119 | case '\\':
120 | result += `\`
121 | j++ // skip next char
122 | default:
123 | result += string(unquoted[j])
124 | }
125 | } else {
126 | result += string(unquoted[j])
127 | }
128 | }
129 | return result
130 | }
131 | }
132 | // No closing quote found, return as is without outer quotes
133 | return input[1:]
134 | }
135 |
136 | if strings.HasPrefix(input, "'") {
137 | // Similar logic for single quotes (no escape processing)
138 | for i := 1; i < len(input); i++ {
139 | if input[i] == '\'' {
140 | return input[1:i]
141 | }
142 | }
143 | return input[1:]
144 | }
145 |
146 | // Unquoted value - look for comment and strip it
147 | if idx := strings.Index(input, "#"); idx != -1 {
148 | return strings.TrimSpace(input[:idx])
149 | }
150 |
151 | return input
152 | }
153 |
154 | // formatValue converts a string value back to env file format
155 | func (c *Codec) formatValue(value string) string {
156 | // Quote if contains spaces or special characters
157 | if strings.ContainsAny(value, " \t\n#=") || value == "" {
158 | escaped := strings.ReplaceAll(value, `\`, `\\`)
159 | escaped = strings.ReplaceAll(escaped, `"`, `\"`)
160 | escaped = strings.ReplaceAll(escaped, "\n", `\n`)
161 | escaped = strings.ReplaceAll(escaped, "\t", `\t`)
162 | return fmt.Sprintf(`"%s"`, escaped)
163 | }
164 | return value
165 | }
166 |
--------------------------------------------------------------------------------
/codec/parquet/parquet.go:
--------------------------------------------------------------------------------
1 | package parquet
2 |
3 | import (
4 | "bytes"
5 | "context"
6 | "fmt"
7 | "github.com/apache/arrow/go/v16/arrow"
8 | "github.com/apache/arrow/go/v16/arrow/array"
9 | "github.com/apache/arrow/go/v16/arrow/memory"
10 | "github.com/apache/arrow/go/v16/parquet"
11 | "github.com/apache/arrow/go/v16/parquet/compress"
12 | "github.com/apache/arrow/go/v16/parquet/file"
13 | "github.com/apache/arrow/go/v16/parquet/pqarrow"
14 | "github.com/goccy/go-json"
15 | "reflect"
16 | )
17 |
18 | type Codec struct{}
19 |
20 | func (c *Codec) Marshal(v any) ([]byte, error) {
21 | rv := reflect.ValueOf(v)
22 | if rv.Kind() != reflect.Slice {
23 | return nil, fmt.Errorf("input data must be a slice")
24 | }
25 |
26 | if rv.Len() == 0 {
27 | return nil, fmt.Errorf("no data to write")
28 | }
29 |
30 | firstElem := rv.Index(0).Interface()
31 | firstElemValue, ok := firstElem.(map[string]any)
32 | if !ok {
33 | return nil, fmt.Errorf("slice elements must be of type map[string]any")
34 | }
35 |
36 | mem := memory.NewGoAllocator()
37 | var fields []arrow.Field
38 |
39 | for key := range firstElemValue {
40 | fields = append(fields, arrow.Field{Name: key, Type: arrow.BinaryTypes.String, Nullable: true})
41 | }
42 |
43 | schema := arrow.NewSchema(fields, nil)
44 |
45 | var buf bytes.Buffer
46 | props := parquet.NewWriterProperties(parquet.WithCompression(compress.Codecs.Snappy))
47 | arrowProps := pqarrow.NewArrowWriterProperties(pqarrow.WithStoreSchema())
48 |
49 | writer, err := pqarrow.NewFileWriter(schema, &buf, props, arrowProps)
50 | if err != nil {
51 | return nil, fmt.Errorf("error creating parquet writer: %v", err)
52 | }
53 | defer writer.Close()
54 |
55 | builders := make([]array.Builder, len(fields))
56 | for i := range fields {
57 | builders[i] = array.NewStringBuilder(mem)
58 | }
59 |
60 | for i := 0; i < rv.Len(); i++ {
61 | recordMap := rv.Index(i).Interface().(map[string]any)
62 | for j, field := range fields {
63 | if value, ok := recordMap[field.Name]; ok {
64 | builders[j].(*array.StringBuilder).Append(fmt.Sprintf("%v", value))
65 | } else {
66 | builders[j].(*array.StringBuilder).AppendNull()
67 | }
68 | }
69 | }
70 |
71 | columns := make([]arrow.Array, len(builders))
72 | for i, builder := range builders {
73 | columns[i] = builder.NewArray()
74 | }
75 |
76 | record := array.NewRecord(schema, columns, int64(rv.Len()))
77 | defer record.Release()
78 |
79 | if err := writer.Write(record); err != nil {
80 | return nil, fmt.Errorf("error writing record to parquet: %v", err)
81 | }
82 |
83 | if err := writer.Close(); err != nil {
84 | return nil, fmt.Errorf("error closing parquet writer: %v", err)
85 | }
86 |
87 | return buf.Bytes(), nil
88 | }
89 |
90 | func (c *Codec) Unmarshal(input []byte, v any) error {
91 | reader := bytes.NewReader(input)
92 |
93 | parquetFile, err := file.NewParquetReader(reader)
94 | if err != nil {
95 | return fmt.Errorf("error creating parquet reader: %v", err)
96 | }
97 | defer parquetFile.Close()
98 |
99 | fileReader, err := pqarrow.NewFileReader(parquetFile, pqarrow.ArrowReadProperties{}, memory.NewGoAllocator())
100 | if err != nil {
101 | return fmt.Errorf("error creating arrow file reader: %v", err)
102 | }
103 |
104 | // Read the whole table
105 | table, err := fileReader.ReadTable(context.Background())
106 | if err != nil {
107 | return fmt.Errorf("error reading table: %v", err)
108 | }
109 | defer table.Release()
110 |
111 | // Convert table to records
112 | tableReader := array.NewTableReader(table, 1000) // batch size
113 | defer tableReader.Release()
114 |
115 | var records []map[string]any
116 |
117 | for tableReader.Next() {
118 | record := tableReader.Record()
119 | schema := record.Schema()
120 | numRows := record.NumRows()
121 | numCols := record.NumCols()
122 |
123 | for i := int64(0); i < numRows; i++ {
124 | rowMap := make(map[string]any)
125 | for j := 0; j < int(numCols); j++ {
126 | field := schema.Field(j)
127 | column := record.Column(j)
128 |
129 | if column.IsNull(int(i)) {
130 | rowMap[field.Name] = nil
131 | } else {
132 | switch arr := column.(type) {
133 | case *array.String:
134 | rowMap[field.Name] = arr.Value(int(i))
135 | case *array.Int64:
136 | rowMap[field.Name] = arr.Value(int(i))
137 | case *array.Float64:
138 | rowMap[field.Name] = arr.Value(int(i))
139 | case *array.Boolean:
140 | rowMap[field.Name] = arr.Value(int(i))
141 | case *array.Int32:
142 | rowMap[field.Name] = arr.Value(int(i))
143 | case *array.Float32:
144 | rowMap[field.Name] = arr.Value(int(i))
145 | default:
146 | rowMap[field.Name] = fmt.Sprintf("%v", column.GetOneForMarshal(int(i)))
147 | }
148 | }
149 | }
150 | records = append(records, rowMap)
151 | }
152 | }
153 |
154 | // Always use JSON marshaling for consistent type handling
155 | jsonData, err := json.Marshal(records)
156 | if err != nil {
157 | return fmt.Errorf("error marshaling to JSON: %v", err)
158 | }
159 |
160 | if err := json.Unmarshal(jsonData, v); err != nil {
161 | return fmt.Errorf("error unmarshaling JSON: %v", err)
162 | }
163 |
164 | return nil
165 | }
166 |
--------------------------------------------------------------------------------
/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/mattn/go-isatty"
13 | "os"
14 | "strings"
15 | // dedicated codec packages and wrappers where appropriate
16 | "github.com/JFryy/qq/codec/csv"
17 | "github.com/JFryy/qq/codec/env"
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 | "github.com/JFryy/qq/codec/msgpack"
25 | "github.com/JFryy/qq/codec/parquet"
26 | proto "github.com/JFryy/qq/codec/proto"
27 | "github.com/JFryy/qq/codec/xml"
28 | "github.com/JFryy/qq/codec/yaml"
29 | )
30 |
31 | // EncodingType represents the supported encoding types as an enum with a string representation
32 | type EncodingType int
33 |
34 | const (
35 | JSON EncodingType = iota
36 | YAML
37 | YML
38 | TOML
39 | HCL
40 | TF
41 | CSV
42 | XML
43 | INI
44 | GRON
45 | HTML
46 | LINE
47 | TXT
48 | PROTO
49 | ENV
50 | PARQUET
51 | MSGPACK
52 | MPK
53 | )
54 |
55 | func (e EncodingType) String() string {
56 | return [...]string{"json", "yaml", "yml", "toml", "hcl", "tf", "csv", "xml", "ini", "gron", "html", "line", "txt", "proto", "env", "parquet", "msgpack", "mpk"}[e]
57 | }
58 |
59 | type Encoding struct {
60 | Ext EncodingType
61 | Unmarshal func([]byte, any) error
62 | Marshal func(any) ([]byte, error)
63 | }
64 |
65 | func GetEncodingType(fileType string) (EncodingType, error) {
66 | fileType = strings.ToLower(fileType)
67 | for _, t := range SupportedFileTypes {
68 | if fileType == t.Ext.String() {
69 | return t.Ext, nil
70 | }
71 | }
72 | return JSON, fmt.Errorf("unsupported file type: %v", fileType)
73 | }
74 |
75 | var (
76 | htm = html.Codec{}
77 | jsn = qqjson.Codec{} // wrapper for go-json marshal
78 | grn = gron.Codec{}
79 | hcltf = hcl.Codec{}
80 | xmll = xml.Codec{}
81 | inii = ini.Codec{}
82 | lines = line.Codec{}
83 | sv = csv.Codec{}
84 | pb = proto.Codec{}
85 | yml = yaml.Codec{}
86 | envCodec = env.Codec{}
87 | parquetCodec = parquet.Codec{}
88 | msgpackCodec = msgpack.Codec{}
89 | )
90 | var SupportedFileTypes = []Encoding{
91 | {JSON, json.Unmarshal, jsn.Marshal},
92 | {YAML, yml.Unmarshal, yml.Marshal},
93 | {YML, yml.Unmarshal, yml.Marshal},
94 | {TOML, toml.Unmarshal, toml.Marshal},
95 | {HCL, hcltf.Unmarshal, hcltf.Marshal},
96 | {TF, hcltf.Unmarshal, hcltf.Marshal},
97 | {CSV, sv.Unmarshal, sv.Marshal},
98 | {XML, xmll.Unmarshal, xmll.Marshal},
99 | {INI, inii.Unmarshal, inii.Marshal},
100 | {GRON, grn.Unmarshal, grn.Marshal},
101 | {HTML, htm.Unmarshal, xmll.Marshal},
102 | {LINE, lines.Unmarshal, jsn.Marshal},
103 | {TXT, lines.Unmarshal, jsn.Marshal},
104 | {PROTO, pb.Unmarshal, jsn.Marshal},
105 | {ENV, envCodec.Unmarshal, envCodec.Marshal},
106 | {PARQUET, parquetCodec.Unmarshal, parquetCodec.Marshal},
107 | {MSGPACK, msgpackCodec.Unmarshal, msgpackCodec.Marshal},
108 | {MPK, msgpackCodec.Unmarshal, msgpackCodec.Marshal},
109 | }
110 |
111 | func Unmarshal(input []byte, inputFileType EncodingType, data any) error {
112 | for _, t := range SupportedFileTypes {
113 | if t.Ext == inputFileType {
114 | err := t.Unmarshal(input, data)
115 | if err != nil {
116 | return fmt.Errorf("error parsing input: %v", err)
117 | }
118 | return nil
119 | }
120 | }
121 | return fmt.Errorf("unsupported input file type: %v", inputFileType)
122 | }
123 |
124 | func Marshal(v any, outputFileType EncodingType) ([]byte, error) {
125 | for _, t := range SupportedFileTypes {
126 | if t.Ext == outputFileType {
127 | var err error
128 | b, err := t.Marshal(v)
129 | if err != nil {
130 | return b, fmt.Errorf("error marshaling result to %s: %v", outputFileType, err)
131 | }
132 | return b, nil
133 | }
134 | }
135 | return nil, fmt.Errorf("unsupported output file type: %v", outputFileType)
136 | }
137 |
138 | func PrettyFormat(s string, fileType EncodingType, raw bool, monochrome bool) (string, error) {
139 | if raw {
140 | var v any
141 | err := Unmarshal([]byte(s), fileType, &v)
142 | if err != nil {
143 | return "", err
144 | }
145 | switch v.(type) {
146 | case map[string]any:
147 | break
148 | case []any:
149 | break
150 | default:
151 | return strings.ReplaceAll(s, "\"", ""), nil
152 | }
153 | }
154 |
155 | if !isatty.IsTerminal(os.Stdout.Fd()) || monochrome {
156 | return s, nil
157 | }
158 |
159 | var lexer chroma.Lexer
160 | // this a workaround for json lexer while we don't have a marshal function dedicated for these formats.
161 | if fileType == CSV || fileType == HTML || fileType == LINE || fileType == TXT || fileType == ENV || fileType == PARQUET || fileType == MSGPACK || fileType == MPK {
162 | lexer = lexers.Get("json")
163 | } else {
164 | lexer = lexers.Get(fileType.String())
165 | if lexer == nil {
166 | lexer = lexers.Fallback
167 | }
168 | }
169 |
170 | if lexer == nil {
171 | return "", fmt.Errorf("unsupported file type for formatting: %v", fileType)
172 | }
173 |
174 | iterator, err := lexer.Tokenise(nil, s)
175 | if err != nil {
176 | return "", fmt.Errorf("error tokenizing input: %v", err)
177 | }
178 |
179 | style := styles.Get("nord")
180 | formatter := formatters.Get("terminal256")
181 | var buffer bytes.Buffer
182 |
183 | err = formatter.Format(&buffer, style, iterator)
184 | if err != nil {
185 | return "", fmt.Errorf("error formatting output: %v", err)
186 | }
187 |
188 | return buffer.String(), nil
189 | }
190 |
191 | func IsBinaryFormat(fileType EncodingType) bool {
192 | return fileType == PARQUET || fileType == MSGPACK || fileType == MPK
193 | }
194 |
--------------------------------------------------------------------------------
/codec/json/json_test.go:
--------------------------------------------------------------------------------
1 | package json
2 |
3 | import (
4 | "encoding/json"
5 | "strings"
6 | "testing"
7 | )
8 |
9 | func TestJSONMarshalBasicTypes(t *testing.T) {
10 | testData := map[string]any{
11 | "string": "hello",
12 | "number": 42,
13 | "float": 3.14,
14 | "boolean": true,
15 | "null": nil,
16 | }
17 |
18 | codec := &Codec{}
19 |
20 | // Test marshaling
21 | data, err := codec.Marshal(testData)
22 | if err != nil {
23 | t.Fatalf("Failed to marshal JSON data: %v", err)
24 | }
25 |
26 | if len(data) == 0 {
27 | t.Fatal("Marshaled data is empty")
28 | }
29 |
30 | // Verify it's valid JSON
31 | var result map[string]any
32 | err = json.Unmarshal(data, &result)
33 | if err != nil {
34 | t.Fatalf("Marshaled data is not valid JSON: %v", err)
35 | }
36 |
37 | // Verify values
38 | if result["string"] != "hello" {
39 | t.Errorf("Expected string 'hello', got %v", result["string"])
40 | }
41 | if result["number"].(float64) != 42 {
42 | t.Errorf("Expected number 42, got %v", result["number"])
43 | }
44 | if result["boolean"] != true {
45 | t.Errorf("Expected boolean true, got %v", result["boolean"])
46 | }
47 | if result["null"] != nil {
48 | t.Errorf("Expected null nil, got %v", result["null"])
49 | }
50 | }
51 |
52 | func TestJSONMarshalArray(t *testing.T) {
53 | testData := []map[string]any{
54 | {"id": 1, "name": "Alice"},
55 | {"id": 2, "name": "Bob"},
56 | }
57 |
58 | codec := &Codec{}
59 |
60 | data, err := codec.Marshal(testData)
61 | if err != nil {
62 | t.Fatalf("Failed to marshal JSON array: %v", err)
63 | }
64 |
65 | // Verify it's valid JSON array
66 | var result []map[string]any
67 | err = json.Unmarshal(data, &result)
68 | if err != nil {
69 | t.Fatalf("Marshaled array is not valid JSON: %v", err)
70 | }
71 |
72 | if len(result) != 2 {
73 | t.Fatalf("Expected 2 items, got %d", len(result))
74 | }
75 |
76 | if result[0]["name"] != "Alice" {
77 | t.Errorf("Expected first name 'Alice', got %v", result[0]["name"])
78 | }
79 | }
80 |
81 | func TestJSONMarshalNested(t *testing.T) {
82 | testData := map[string]any{
83 | "user": map[string]any{
84 | "name": "Alice",
85 | "metadata": map[string]any{
86 | "department": "Engineering",
87 | "level": "Senior",
88 | },
89 | },
90 | "tags": []string{"golang", "json"},
91 | }
92 |
93 | codec := &Codec{}
94 |
95 | data, err := codec.Marshal(testData)
96 | if err != nil {
97 | t.Fatalf("Failed to marshal nested JSON: %v", err)
98 | }
99 |
100 | // Verify it's valid JSON
101 | var result map[string]any
102 | err = json.Unmarshal(data, &result)
103 | if err != nil {
104 | t.Fatalf("Marshaled nested data is not valid JSON: %v", err)
105 | }
106 |
107 | // Navigate nested structure
108 | user := result["user"].(map[string]any)
109 | metadata := user["metadata"].(map[string]any)
110 |
111 | if metadata["department"] != "Engineering" {
112 | t.Errorf("Expected department 'Engineering', got %v", metadata["department"])
113 | }
114 |
115 | tags := result["tags"].([]any)
116 | if len(tags) != 2 || tags[0] != "golang" {
117 | t.Errorf("Expected tags array with 'golang', got %v", tags)
118 | }
119 | }
120 |
121 | func TestJSONMarshalSpecialCharacters(t *testing.T) {
122 | testData := map[string]any{
123 | "quotes": "He said \"Hello\"",
124 | "backslash": "Path\\to\\file",
125 | "unicode": "Hello 世界 🌍",
126 | "newline": "Line 1\nLine 2",
127 | "tab": "Col1\tCol2",
128 | }
129 |
130 | codec := &Codec{}
131 |
132 | data, err := codec.Marshal(testData)
133 | if err != nil {
134 | t.Fatalf("Failed to marshal JSON with special chars: %v", err)
135 | }
136 |
137 | // Verify it's valid JSON
138 | var result map[string]any
139 | err = json.Unmarshal(data, &result)
140 | if err != nil {
141 | t.Fatalf("Marshaled data with special chars is not valid JSON: %v", err)
142 | }
143 |
144 | // Verify special characters are preserved
145 | if result["quotes"] != "He said \"Hello\"" {
146 | t.Errorf("Quotes not preserved: %v", result["quotes"])
147 | }
148 | if result["unicode"] != "Hello 世界 🌍" {
149 | t.Errorf("Unicode not preserved: %v", result["unicode"])
150 | }
151 | }
152 |
153 | func TestJSONMarshalFormatting(t *testing.T) {
154 | testData := map[string]any{
155 | "key1": "value1",
156 | "key2": "value2",
157 | }
158 |
159 | codec := &Codec{}
160 |
161 | data, err := codec.Marshal(testData)
162 | if err != nil {
163 | t.Fatalf("Failed to marshal JSON: %v", err)
164 | }
165 |
166 | jsonStr := string(data)
167 |
168 | // Should be pretty-printed (indented)
169 | if !strings.Contains(jsonStr, " ") {
170 | t.Error("JSON should be indented but appears to be compact")
171 | }
172 |
173 | // Should not have trailing newline (trimmed)
174 | if strings.HasSuffix(jsonStr, "\n") {
175 | t.Error("JSON should not have trailing newline")
176 | }
177 |
178 | // Should not escape HTML (SetEscapeHTML(false))
179 | testDataWithHTML := map[string]any{
180 | "html": "test
",
181 | }
182 |
183 | dataHTML, err := codec.Marshal(testDataWithHTML)
184 | if err != nil {
185 | t.Fatalf("Failed to marshal JSON with HTML: %v", err)
186 | }
187 |
188 | if !strings.Contains(string(dataHTML), "") {
189 | t.Error("HTML should not be escaped")
190 | }
191 | }
192 |
193 | func TestJSONMarshalEmptyValues(t *testing.T) {
194 | testCases := []struct {
195 | name string
196 | data any
197 | }{
198 | {"empty map", map[string]any{}},
199 | {"empty array", []any{}},
200 | {"empty string", ""},
201 | {"zero number", 0},
202 | {"false boolean", false},
203 | }
204 |
205 | codec := &Codec{}
206 |
207 | for _, tc := range testCases {
208 | t.Run(tc.name, func(t *testing.T) {
209 | data, err := codec.Marshal(tc.data)
210 | if err != nil {
211 | t.Fatalf("Failed to marshal %s: %v", tc.name, err)
212 | }
213 |
214 | // Should be valid JSON
215 | var result any
216 | err = json.Unmarshal(data, &result)
217 | if err != nil {
218 | t.Fatalf("Marshaled %s is not valid JSON: %v", tc.name, err)
219 | }
220 | })
221 | }
222 | }
223 |
--------------------------------------------------------------------------------
/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 monochrome bool
22 | var encodings string
23 | for _, t := range codec.SupportedFileTypes {
24 | encodings += t.Ext.String() + ", "
25 | }
26 | encodings = strings.TrimSuffix(encodings, ", ")
27 | v := "v0.3.2"
28 | 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)
29 | cmd := &cobra.Command{
30 | Use: "qq [expression] [file] [flags] \n cat [file] | qq [expression] [flags] \n qq -I file",
31 | Short: "qq - JQ processing with conversions for popular configuration formats.",
32 |
33 | Long: desc,
34 | Run: func(cmd *cobra.Command, args []string) {
35 | if version {
36 | fmt.Println("qq version", v)
37 | os.Exit(0)
38 | }
39 | if len(args) == 0 && !cmd.Flags().Changed("input") && !cmd.Flags().Changed("output") && !cmd.Flags().Changed("raw-input") && isTerminal(os.Stdin) {
40 | err := cmd.Help()
41 | if err != nil {
42 | fmt.Println(err)
43 | os.Exit(1)
44 | }
45 | os.Exit(0)
46 | }
47 | handleCommand(cmd, args, inputType, outputType, rawOutput, help, interactive, monochrome)
48 | },
49 | }
50 | cmd.Flags().StringVarP(&inputType, "input", "i", "json", "specify input file type, only required on parsing stdin.")
51 | cmd.Flags().StringVarP(&outputType, "output", "o", "json", "specify output file type by extension name. This is inferred from extension if passing file position argument.")
52 | cmd.Flags().BoolVarP(&rawOutput, "raw-output", "r", false, "output strings without escapes and quotes.")
53 | cmd.Flags().BoolVarP(&help, "help", "h", false, "help for qq")
54 | cmd.Flags().BoolVarP(&version, "version", "v", false, "version for qq")
55 | cmd.Flags().BoolVarP(&interactive, "interactive", "I", false, "interactive mode for qq")
56 | cmd.Flags().BoolVarP(&monochrome, "monochrome-output", "M", false, "disable colored output")
57 |
58 | return cmd
59 | }
60 |
61 | func handleCommand(cmd *cobra.Command, args []string, inputtype string, outputtype string, rawInput bool, help bool, interactive bool, monochrome bool) {
62 | var input []byte
63 | var err error
64 | var expression string
65 | var filename string
66 | if help {
67 | val := CreateRootCmd().Help()
68 | fmt.Println(val)
69 | os.Exit(0)
70 | }
71 |
72 | // handle input with stdin or file
73 | switch len(args) {
74 | case 0:
75 | expression = "."
76 | input, err = io.ReadAll(os.Stdin)
77 | if err != nil {
78 | fmt.Println(err)
79 | os.Exit(1)
80 | }
81 | case 1:
82 | if isFile(args[0]) {
83 | filename = args[0]
84 | expression = "."
85 | // read file content by name
86 | input, err = os.ReadFile(args[0])
87 | if err != nil {
88 | fmt.Println(err)
89 | os.Exit(1)
90 | }
91 |
92 | } else {
93 | expression = args[0]
94 | input, err = io.ReadAll(os.Stdin)
95 | if err != nil {
96 | fmt.Println(err)
97 | os.Exit(1)
98 | }
99 | }
100 | case 2:
101 | filename = args[1]
102 | expression = args[0]
103 | input, err = os.ReadFile(args[1])
104 | if err != nil {
105 | fmt.Println(err)
106 | os.Exit(1)
107 | }
108 |
109 | }
110 |
111 | var inputCodec codec.EncodingType
112 | // Check if -i flag was explicitly set by user
113 | inputFlagSet := cmd.Flags().Changed("input")
114 |
115 | if inputFlagSet {
116 | // -i flag takes precedence over file extension
117 | inputCodec, err = codec.GetEncodingType(inputtype)
118 | } else if filename != "" {
119 | // Infer from file extension when no -i flag is set
120 | inputCodec = inferFileType(filename)
121 | } else {
122 | inputCodec, err = codec.GetEncodingType(inputtype)
123 | }
124 | if err != nil {
125 | fmt.Println(err)
126 | os.Exit(1)
127 | }
128 | var data any
129 | err = codec.Unmarshal(input, inputCodec, &data)
130 | if err != nil {
131 | fmt.Println(err)
132 | }
133 |
134 | outputCodec, err := codec.GetEncodingType(outputtype)
135 | if err != nil {
136 | fmt.Println(err)
137 | os.Exit(1)
138 | }
139 |
140 | if !interactive {
141 | query, err := gojq.Parse(expression)
142 | if err != nil {
143 | fmt.Printf("Error parsing jq expression: %v\n", err)
144 | os.Exit(1)
145 | }
146 |
147 | executeQuery(query, data, outputCodec, rawInput, monochrome)
148 | os.Exit(0)
149 | }
150 |
151 | b, err := codec.Marshal(data, outputCodec)
152 | s := string(b)
153 | if err != nil {
154 | fmt.Println(err)
155 | os.Exit(1)
156 | }
157 |
158 | tui.Interact(s)
159 | os.Exit(0)
160 | }
161 |
162 | func isTerminal(f *os.File) bool {
163 | info, err := f.Stat()
164 | if err != nil {
165 | return false
166 | }
167 | return (info.Mode() & os.ModeCharDevice) != 0
168 | }
169 |
170 | func isFile(path string) bool {
171 | info, err := os.Stat(path)
172 | if err != nil {
173 | return false
174 | }
175 | return !info.IsDir()
176 | }
177 |
178 | func inferFileType(fName string) codec.EncodingType {
179 | ext := strings.ToLower(filepath.Ext(fName))
180 |
181 | for _, t := range codec.SupportedFileTypes {
182 | if ext == "."+t.Ext.String() {
183 | return t.Ext
184 | }
185 | }
186 | return codec.JSON
187 | }
188 |
189 | func executeQuery(query *gojq.Query, data any, fileType codec.EncodingType, rawOut bool, monochrome bool) {
190 | iter := query.Run(data)
191 | for {
192 | v, ok := iter.Next()
193 | if !ok {
194 | break
195 | }
196 | if err, ok := v.(error); ok {
197 | fmt.Printf("Error executing jq expression: %v\n", err)
198 | os.Exit(1)
199 | }
200 | b, err := codec.Marshal(v, fileType)
201 | if err != nil {
202 | fmt.Printf("Error formatting result: %v\n", err)
203 | os.Exit(1)
204 | }
205 |
206 | if codec.IsBinaryFormat(fileType) {
207 | // For binary formats, write directly to stdout as raw bytes
208 | os.Stdout.Write(b)
209 | } else {
210 | s := string(b)
211 | r, _ := codec.PrettyFormat(s, fileType, rawOut, monochrome)
212 | fmt.Println(r)
213 | }
214 | }
215 | }
216 |
--------------------------------------------------------------------------------
/codec/csv/csv_test.go:
--------------------------------------------------------------------------------
1 | package csv
2 |
3 | import (
4 | "strings"
5 | "testing"
6 | )
7 |
8 | func TestBasicCSVMarshalUnmarshal(t *testing.T) {
9 | testData := []map[string]any{
10 | {
11 | "ID": 1,
12 | "Name": "Alice",
13 | "Age": 30,
14 | "Active": true,
15 | "Score": 95.5,
16 | "Department": "Engineering",
17 | },
18 | {
19 | "ID": 2,
20 | "Name": "Bob",
21 | "Age": 25,
22 | "Active": false,
23 | "Score": 87.2,
24 | "Department": "Sales",
25 | },
26 | {
27 | "ID": 3,
28 | "Name": "Charlie",
29 | "Age": 35,
30 | "Active": true,
31 | "Score": 92.0,
32 | "Department": "Engineering",
33 | },
34 | }
35 |
36 | codec := &Codec{}
37 |
38 | // Test marshaling
39 | data, err := codec.Marshal(testData)
40 | if err != nil {
41 | t.Fatalf("Failed to marshal CSV data: %v", err)
42 | }
43 |
44 | if len(data) == 0 {
45 | t.Fatal("Marshaled data is empty")
46 | }
47 |
48 | // Check that it contains headers
49 | csvStr := string(data)
50 | if !strings.Contains(csvStr, "ID") || !strings.Contains(csvStr, "Name") {
51 | t.Error("CSV output missing expected headers")
52 | }
53 |
54 | // Test unmarshaling
55 | var result []map[string]any
56 | err = codec.Unmarshal(data, &result)
57 | if err != nil {
58 | t.Fatalf("Failed to unmarshal CSV data: %v", err)
59 | }
60 |
61 | // Verify length
62 | if len(result) != 3 {
63 | t.Fatalf("Expected 3 records, got %d", len(result))
64 | }
65 |
66 | // Verify first record structure
67 | // Note: Numbers become float64 due to JSON round-trip in unmarshal process
68 | first := result[0]
69 | if first["ID"].(float64) != 1 {
70 | t.Errorf("Expected ID 1, got %v", first["ID"])
71 | }
72 | if first["Name"] != "Alice" {
73 | t.Errorf("Expected Name 'Alice', got %v", first["Name"])
74 | }
75 | if first["Age"].(float64) != 30 {
76 | t.Errorf("Expected Age 30, got %v", first["Age"])
77 | }
78 | if first["Active"] != true {
79 | t.Errorf("Expected Active true, got %v", first["Active"])
80 | }
81 | if first["Score"].(float64) != 95.5 {
82 | t.Errorf("Expected Score 95.5, got %v", first["Score"])
83 | }
84 | }
85 |
86 | func TestCSVWithSpecialCharacters(t *testing.T) {
87 | testData := []map[string]any{
88 | {
89 | "Name": "John, Jr.",
90 | "Description": "He said \"Hello world\"",
91 | "Notes": "Multi\nline\ntext",
92 | },
93 | {
94 | "Name": "Jane",
95 | "Description": "Simple text",
96 | "Notes": "Normal notes",
97 | },
98 | }
99 |
100 | codec := &Codec{}
101 |
102 | // Test marshaling
103 | data, err := codec.Marshal(testData)
104 | if err != nil {
105 | t.Fatalf("Failed to marshal CSV data with special chars: %v", err)
106 | }
107 |
108 | // Test unmarshaling
109 | var result []map[string]any
110 | err = codec.Unmarshal(data, &result)
111 | if err != nil {
112 | t.Fatalf("Failed to unmarshal CSV data with special chars: %v", err)
113 | }
114 |
115 | // Verify length
116 | if len(result) != 2 {
117 | t.Fatalf("Expected 2 records, got %d", len(result))
118 | }
119 |
120 | // Verify special characters are preserved
121 | first := result[0]
122 | if first["Name"] != "John, Jr." {
123 | t.Errorf("Expected Name 'John, Jr.', got %v", first["Name"])
124 | }
125 | if first["Description"] != "He said \"Hello world\"" {
126 | t.Errorf("Expected quoted text, got %v", first["Description"])
127 | }
128 | }
129 |
130 | func TestEmptyCSVData(t *testing.T) {
131 | codec := &Codec{}
132 |
133 | // Test empty slice
134 | emptyData := []map[string]any{}
135 | _, err := codec.Marshal(emptyData)
136 | if err == nil {
137 | t.Error("Expected error for empty data, got nil")
138 | }
139 | }
140 |
141 | func TestCSVDelimiterDetection(t *testing.T) {
142 | // Test with semicolon delimiter
143 | csvData := "Name;Age;City\nAlice;30;New York\nBob;25;London"
144 |
145 | codec := &Codec{}
146 | var result []map[string]any
147 | err := codec.Unmarshal([]byte(csvData), &result)
148 | if err != nil {
149 | t.Fatalf("Failed to unmarshal semicolon-delimited CSV: %v", err)
150 | }
151 |
152 | if len(result) != 2 {
153 | t.Fatalf("Expected 2 records, got %d", len(result))
154 | }
155 |
156 | if result[0]["Name"] != "Alice" {
157 | t.Errorf("Expected Name 'Alice', got %v", result[0]["Name"])
158 | }
159 | if result[0]["Age"].(float64) != 30 {
160 | t.Errorf("Expected Age 30, got %v", result[0]["Age"])
161 | }
162 | }
163 |
164 | func TestCSVWithMissingFields(t *testing.T) {
165 | csvData := "Name,Age,City\nAlice,30,New York\nBob,,London\nCharlie,35,"
166 |
167 | codec := &Codec{}
168 | var result []map[string]any
169 | err := codec.Unmarshal([]byte(csvData), &result)
170 | if err != nil {
171 | t.Fatalf("Failed to unmarshal CSV with missing fields: %v", err)
172 | }
173 |
174 | if len(result) != 3 {
175 | t.Fatalf("Expected 3 records, got %d", len(result))
176 | }
177 |
178 | // Check Bob's missing age
179 | if result[1]["Age"] != "" {
180 | t.Errorf("Expected empty Age for Bob, got %v", result[1]["Age"])
181 | }
182 |
183 | // Check Charlie's missing city
184 | if result[2]["City"] != "" {
185 | t.Errorf("Expected empty City for Charlie, got %v", result[2]["City"])
186 | }
187 | }
188 |
189 | func TestCSVRoundTrip(t *testing.T) {
190 | originalData := []map[string]any{
191 | {
192 | "StringField": "test string",
193 | "NumberField": 42,
194 | "FloatField": 3.14159,
195 | "BoolField": true,
196 | },
197 | {
198 | "StringField": "another string",
199 | "NumberField": -17,
200 | "FloatField": -2.718,
201 | "BoolField": false,
202 | },
203 | }
204 |
205 | codec := &Codec{}
206 |
207 | // Marshal original data
208 | data, err := codec.Marshal(originalData)
209 | if err != nil {
210 | t.Fatalf("Failed to marshal data: %v", err)
211 | }
212 |
213 | // Unmarshal to get result
214 | var result []map[string]any
215 | err = codec.Unmarshal(data, &result)
216 | if err != nil {
217 | t.Fatalf("Failed to unmarshal data: %v", err)
218 | }
219 |
220 | // Verify structure is preserved
221 | if len(result) != 2 {
222 | t.Fatalf("Expected 2 records, got %d", len(result))
223 | }
224 |
225 | // Check first record
226 | first := result[0]
227 | if first["StringField"] != "test string" {
228 | t.Errorf("String field mismatch: %v", first["StringField"])
229 | }
230 | // Numbers become float64 due to JSON round-trip
231 | if first["NumberField"].(float64) != 42 {
232 | t.Errorf("Number field mismatch: %v", first["NumberField"])
233 | }
234 | if first["BoolField"] != true {
235 | t.Errorf("Bool field mismatch: %v", first["BoolField"])
236 | }
237 | }
238 |
--------------------------------------------------------------------------------
/codec/env/env_test.go:
--------------------------------------------------------------------------------
1 | package env
2 |
3 | import (
4 | "encoding/json"
5 | "testing"
6 | )
7 |
8 | func TestBasicEnvParsing(t *testing.T) {
9 | envContent := `# Database configuration
10 | DATABASE_URL=postgresql://user:pass@localhost/db
11 | DATABASE_PORT=5432
12 | DEBUG=true
13 |
14 | # API Configuration
15 | API_KEY="secret-key-with-spaces"
16 | API_TIMEOUT=30.5
17 | FEATURE_ENABLED=false
18 |
19 | # Export example
20 | export PATH="/usr/local/bin:$PATH"`
21 |
22 | codec := Codec{}
23 | var result map[string]string
24 |
25 | err := codec.Unmarshal([]byte(envContent), &result)
26 | if err != nil {
27 | t.Fatalf("Failed to unmarshal env: %v", err)
28 | }
29 |
30 | // Test DATABASE_URL
31 | if result["DATABASE_URL"] != "postgresql://user:pass@localhost/db" {
32 | t.Errorf("Expected DATABASE_URL value, got %v", result["DATABASE_URL"])
33 | }
34 |
35 | // Test DATABASE_PORT (now returned as string)
36 | if result["DATABASE_PORT"] != "5432" {
37 | t.Errorf("Expected DATABASE_PORT value '5432', got %v", result["DATABASE_PORT"])
38 | }
39 |
40 | // Test DEBUG (now returned as string)
41 | if result["DEBUG"] != "true" {
42 | t.Errorf("Expected DEBUG value 'true', got %v", result["DEBUG"])
43 | }
44 |
45 | // Test API_KEY (quoted string)
46 | if result["API_KEY"] != "secret-key-with-spaces" {
47 | t.Errorf("Expected API_KEY value, got %v", result["API_KEY"])
48 | }
49 |
50 | // Test API_TIMEOUT (now returned as string)
51 | if result["API_TIMEOUT"] != "30.5" {
52 | t.Errorf("Expected API_TIMEOUT value '30.5', got %v", result["API_TIMEOUT"])
53 | }
54 |
55 | // Test export (simplified - we just get the value)
56 | if result["PATH"] != "/usr/local/bin:$PATH" {
57 | t.Errorf("Expected PATH value, got %v", result["PATH"])
58 | }
59 | }
60 |
61 | func TestCommentsAndInlineComments(t *testing.T) {
62 | envContent := `# This is a comment
63 | API_URL=https://api.example.com # Production API
64 | SECRET_KEY="my-secret" # Keep this safe
65 | PORT=8080`
66 |
67 | codec := Codec{}
68 | var result map[string]string
69 |
70 | err := codec.Unmarshal([]byte(envContent), &result)
71 | if err != nil {
72 | t.Fatalf("Failed to unmarshal env: %v", err)
73 | }
74 |
75 | // Test that values are extracted correctly (comments are ignored in simplified version)
76 | if result["API_URL"] != "https://api.example.com" {
77 | t.Errorf("Expected API_URL value, got %v", result["API_URL"])
78 | }
79 |
80 | // Test quoted value (comment is ignored)
81 | if result["SECRET_KEY"] != "my-secret" {
82 | t.Errorf("Expected SECRET_KEY value 'my-secret', got %v", result["SECRET_KEY"])
83 | }
84 |
85 | if result["PORT"] != "8080" {
86 | t.Errorf("Expected PORT value '8080', got %v", result["PORT"])
87 | }
88 | }
89 |
90 | func TestSpecialValues(t *testing.T) {
91 | envContent := `EMPTY_VALUE=""
92 | ZERO_VALUE=0
93 | FALSE_VALUE=false
94 | TRUE_VALUE=yes
95 | NULL_VALUE=
96 | SPACES_VALUE=" spaces "`
97 |
98 | codec := Codec{}
99 | var result map[string]string
100 |
101 | err := codec.Unmarshal([]byte(envContent), &result)
102 | if err != nil {
103 | t.Fatalf("Failed to unmarshal env: %v", err)
104 | }
105 |
106 | // Test empty quoted string
107 | if result["EMPTY_VALUE"] != "" {
108 | t.Errorf("Expected empty string, got %v", result["EMPTY_VALUE"])
109 | }
110 |
111 | // Test zero (now returned as string)
112 | if result["ZERO_VALUE"] != "0" {
113 | t.Errorf("Expected '0', got %v", result["ZERO_VALUE"])
114 | }
115 |
116 | // Test boolean variations (now returned as strings)
117 | if result["TRUE_VALUE"] != "yes" {
118 | t.Errorf("Expected 'yes', got %v", result["TRUE_VALUE"])
119 | }
120 |
121 | if result["FALSE_VALUE"] != "false" {
122 | t.Errorf("Expected 'false', got %v", result["FALSE_VALUE"])
123 | }
124 |
125 | // Test string with spaces
126 | if result["SPACES_VALUE"] != " spaces " {
127 | t.Errorf("Expected ' spaces ', got %v", result["SPACES_VALUE"])
128 | }
129 |
130 | // Test empty unquoted value
131 | if result["NULL_VALUE"] != "" {
132 | t.Errorf("Expected empty string for NULL_VALUE, got %v", result["NULL_VALUE"])
133 | }
134 | }
135 |
136 | func TestMarshaling(t *testing.T) {
137 | original := map[string]string{
138 | "DATABASE_URL": "postgresql://localhost/db",
139 | "DEBUG": "true",
140 | "PORT": "8080",
141 | "API_KEY": "secret with spaces",
142 | }
143 |
144 | codec := Codec{}
145 | data, err := codec.Marshal(original)
146 | if err != nil {
147 | t.Fatalf("Failed to marshal env: %v", err)
148 | }
149 |
150 | // Should be able to parse it back
151 | var result map[string]string
152 | err = codec.Unmarshal(data, &result)
153 | if err != nil {
154 | t.Fatalf("Failed to unmarshal marshaled env: %v", err)
155 | }
156 |
157 | // Check round-trip values
158 | if result["DATABASE_URL"] != "postgresql://localhost/db" {
159 | t.Errorf("Marshaling round-trip failed for DATABASE_URL")
160 | }
161 | if result["DEBUG"] != "true" {
162 | t.Errorf("Marshaling round-trip failed for DEBUG")
163 | }
164 | if result["PORT"] != "8080" {
165 | t.Errorf("Marshaling round-trip failed for PORT")
166 | }
167 | if result["API_KEY"] != "secret with spaces" {
168 | t.Errorf("Marshaling round-trip failed for API_KEY")
169 | }
170 | }
171 |
172 | func TestEscapeSequences(t *testing.T) {
173 | envContent := `MULTILINE="Line 1\nLine 2\tTabbed"
174 | ESCAPED_QUOTES="He said \"Hello\""
175 | BACKSLASHES="Path\\to\\file"`
176 |
177 | codec := Codec{}
178 | var result map[string]string
179 |
180 | err := codec.Unmarshal([]byte(envContent), &result)
181 | if err != nil {
182 | t.Fatalf("Failed to unmarshal env: %v", err)
183 | }
184 |
185 | // Test multiline with escape sequences
186 | expected := "Line 1\nLine 2\tTabbed"
187 | if result["MULTILINE"] != expected {
188 | t.Errorf("Expected %q, got %q", expected, result["MULTILINE"])
189 | }
190 |
191 | // Test escaped quotes
192 | expectedQuotes := `He said "Hello"`
193 | if result["ESCAPED_QUOTES"] != expectedQuotes {
194 | t.Errorf("Expected %q, got %q", expectedQuotes, result["ESCAPED_QUOTES"])
195 | }
196 |
197 | // Test backslashes
198 | expectedBackslashes := `Path\to\file`
199 | if result["BACKSLASHES"] != expectedBackslashes {
200 | t.Errorf("Expected %q, got %q", expectedBackslashes, result["BACKSLASHES"])
201 | }
202 | }
203 |
204 | func TestJSONCompatibility(t *testing.T) {
205 | envContent := `API_KEY=secret123
206 | DEBUG=true
207 | PORT=8080
208 | TIMEOUT=30.5`
209 |
210 | codec := Codec{}
211 | result, err := codec.Parse(envContent)
212 | if err != nil {
213 | t.Fatalf("Failed to parse env: %v", err)
214 | }
215 |
216 | // Should be JSON-serializable
217 | jsonData, err := json.Marshal(result)
218 | if err != nil {
219 | t.Fatalf("Failed to marshal to JSON: %v", err)
220 | }
221 |
222 | // Should be JSON-deserializable
223 | var jsonResult map[string]string
224 | err = json.Unmarshal(jsonData, &jsonResult)
225 | if err != nil {
226 | t.Fatalf("Failed to unmarshal from JSON: %v", err)
227 | }
228 |
229 | // Verify structure is preserved (simplified format)
230 | if jsonResult["API_KEY"] != "secret123" {
231 | t.Error("JSON round-trip failed")
232 | }
233 | if jsonResult["DEBUG"] != "true" {
234 | t.Error("JSON round-trip failed for DEBUG")
235 | }
236 | if jsonResult["PORT"] != "8080" {
237 | t.Error("JSON round-trip failed for PORT")
238 | }
239 | }
240 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # qq
2 |
3 | [](https://github.com/JFryy/qq/actions/workflows/go.yml)
4 | [](https://github.com/JFryy/qq/actions/workflows/docker-image.yml)
5 | [](https://golang.org/)
6 | [](https://github.com/JFryy/qq/blob/main/LICENSE)
7 | [](https://github.com/JFryy/qq/releases)
8 | [](https://hub.docker.com/r/jfryy/qq)
9 |
10 | `qq` is an 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.
11 |
12 | ## Usage
13 |
14 | Here's some example usage, this emphasizes the interactive mode for demonstration, but `qq` is designed for usage in shell scripts.
15 | 
16 |
17 | ```sh
18 | # JSON is default in and output.
19 | cat file.${ext} | qq -i ${ext}
20 |
21 | # Extension is parsed, no need for input flag
22 | qq '.' file.xml
23 |
24 | # random example: query xml, grep with gron using qq io and output as json
25 | qq file.xml -o gron | grep -vE "sweet.potatoes" | qq -i gron
26 |
27 | # get some content from a site with html input
28 | curl motherfuckingwebsite.com | bin/qq -i html '.html.body.ul.li[0]'
29 |
30 | # interactive query builder mode on target file
31 | qq . file.json --interactive
32 | ```
33 |
34 | ## Installation
35 |
36 | From brew:
37 |
38 | ```shell
39 | brew install jfryy/tap/qq
40 | ```
41 |
42 | From [AUR](https://aur.archlinux.org/packages/qq-git) (ArchLinux):
43 |
44 | ```shell
45 | yay qq-git
46 | ```
47 |
48 | From source (requires `go` `>=1.22.4`)
49 | ```shell
50 | make install
51 | ```
52 |
53 | Download at releases [here](https://github.com/JFryy/qq/releases).
54 |
55 | Docker quickstart:
56 |
57 | ```shell
58 | # install the image
59 | docker pull jfryy/qq
60 |
61 | # run an example
62 | echo '{"foo":"bar"}' | docker run -i jfryy/qq '.foo = "bazz"' -o tf
63 | ```
64 |
65 | ## Background
66 |
67 | `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.
68 |
69 | ## Features
70 |
71 | * Support a wide range of configuration formats and transform them interchangeably between each other.
72 | * Quick and comprehensive querying of configuration formats without needing a pipeline of dedicated tools.
73 | * Provide an interactive mode for building queries with autocomplete and realtime rendering preview.
74 | * `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.
75 |
76 | ### Rough Benchmarks
77 |
78 | 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.
79 |
80 | ```shell
81 | $ du -h large-file.json
82 | 25M large-file.json
83 | ```
84 |
85 | ```shell
86 | # gron large file bench
87 |
88 | $ time gron large-file.json --no-sort | rg -v '[1-4]' | gron --ungron --no-sort > /dev/null 2>&1
89 | gron large-file.json --no-sort 2.58s user 0.48s system 153% cpu 1.990 total
90 | rg -v '[1-4]' 0.18s user 0.24s system 21% cpu 1.991 total
91 | gron --ungron --no-sort > /dev/null 2>&1 7.68s user 1.15s system 197% cpu 4.475 total
92 |
93 | $ time qq -o gron large-file.json | rg -v '[1-4]' | qq -i gron > /dev/null 2>&1
94 | qq -o gron large-file.json 0.81s user 0.09s system 128% cpu 0.706 total
95 | rg -v '[1-4]' 0.02s user 0.01s system 5% cpu 0.706 total
96 | qq -i gron > /dev/null 2>&1 0.07s user 0.01s system 11% cpu 0.741 total
97 |
98 | # yq large file bench
99 |
100 | $ time yq large-file.json -M -o yaml > /dev/null 2>&1
101 | yq large-file.json -M -o yaml > /dev/null 2>&1 4.02s user 0.31s system 208% cpu 2.081 total
102 |
103 | $ time qq large-file.json -o yaml > /dev/null 2>&1
104 | qq large-file.json -o yaml > /dev/null 2>&1 2.72s user 0.16s system 190% cpu 1.519 total
105 | ```
106 |
107 | ## Supported Formats
108 |
109 | | Format | Input | Output |
110 | |-------------|----------------|----------------|
111 | | JSON | ✅ Supported | ✅ Supported |
112 | | YAML | ✅ Supported | ✅ Supported |
113 | | TOML | ✅ Supported | ✅ Supported |
114 | | XML | ✅ Supported | ✅ Supported |
115 | | INI | ✅ Supported | ✅ Supported |
116 | | HCL | ✅ Supported | ✅ Supported |
117 | | TF | ✅ Supported | ✅ Supported |
118 | | GRON | ✅ Supported | ✅ Supported |
119 | | CSV | ✅ Supported | ✅ Supported |
120 | | Proto (.proto) | ✅ Supported | ❌ Not Supported |
121 | | HTML | ✅ Supported | ✅ Supported |
122 | | TXT (newline)| ✅ Supported | ❌ Not Supported |
123 | | ENV | ✅ Supported | ❌ Not Supported |
124 | | PARQUET | ✅ Supported | ✅ Supported |
125 | | MSGPACK | ✅ Supported | ✅ Supported |
126 |
127 | ## Caveats
128 |
129 | 1. `qq` is not a full `jq` replacement, some flags may or may not be supported.
130 | 3. `qq` is under active development, more codecs in the future may be supported along with improvements to `interactive mode`.
131 |
132 | ## Contributions
133 |
134 | All contributions are welcome to `qq`, especially for upkeep/optimization/addition of new encodings.
135 |
136 | ## Thanks and Acknowledgements / Related Projects
137 |
138 | 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.
139 | Nevertheless, I hope this project can be useful to others, and I hope to contribute back to the community with this project.
140 |
141 | * [gojq](https://github.com/itchyny/gojq): `gojq` is a pure Go implementation of jq. It is used to power the query engine of qq.
142 | * [fq](https://github.com/wader/fq) : fq is a `jq` like tool for querying a wide array of binary formats.
143 | * [jq](https://github.com/jqlang/jq): `jq` is a lightweight and flexible command-line JSON processor.
144 | * [gron](https://github.com/tomnomnom/gron): gron transforms JSON into discrete assignments that are easy to grep.
145 | * [yq](https://github.com/mikefarah/yq): yq is a lightweight and flexible command-line YAML (and much more) processor.
146 | * [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.
147 | * [go-toml](https://github.com/BurntSushi/toml): go-toml is a TOML parser for Golang with reflection.
148 |
--------------------------------------------------------------------------------
/tests/test.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | set -eo pipefail
4 |
5 | # Global counters
6 | total_tests=0
7 | passed_tests=0
8 | skipped_tests=0
9 | failed_tests=0
10 |
11 | # Cleanup function
12 | cleanup() {
13 | if [[ $failed_tests -gt 0 ]]; then
14 | print "red" "Tests failed! $failed_tests failures out of $total_tests tests"
15 | exit 1
16 | fi
17 | }
18 | trap cleanup EXIT
19 |
20 | # Validation function
21 | validate_prerequisites() {
22 | local missing_deps=()
23 |
24 | if ! command -v jq &> /dev/null; then
25 | missing_deps+=("jq")
26 | fi
27 |
28 | if [[ ! -f "bin/qq" ]]; then
29 | print "red" "Error: bin/qq not found. Run 'make build' first."
30 | exit 1
31 | fi
32 |
33 | if [[ ! -d "tests" ]]; then
34 | print "red" "Error: tests directory not found"
35 | exit 1
36 | fi
37 |
38 | if [[ ${#missing_deps[@]} -gt 0 ]]; then
39 | print "red" "Error: Missing dependencies: ${missing_deps[*]}"
40 | print "red" "Please install the missing dependencies and try again."
41 | exit 1
42 | fi
43 | }
44 |
45 | # Enhanced print function
46 | print() {
47 | local color="$1"
48 | local message="$2"
49 | case $color in
50 | red)
51 | echo -e "\033[0;31m$message\033[0m" >&2
52 | ;;
53 | green)
54 | echo -e "\033[0;32m$message\033[0m"
55 | ;;
56 | yellow)
57 | echo -e "\033[0;33m$message\033[0m"
58 | ;;
59 | blue)
60 | echo -e "\033[0;34m$message\033[0m"
61 | ;;
62 | *)
63 | echo "$message"
64 | ;;
65 | esac
66 | }
67 |
68 | # Skip logic function
69 | should_skip_conversion() {
70 | local input="$1"
71 | local output="$2"
72 | local reason=""
73 |
74 | # CSV compatibility rules
75 | if [[ "$input" == "csv" && "$output" != "csv" ]]; then
76 | reason="CSV to non-CSV conversion not supported"
77 | elif [[ "$input" != "csv" && "$output" == "csv" ]]; then
78 | reason="Non-CSV to CSV conversion not supported"
79 | # Parquet compatibility rules
80 | elif [[ "$output" == "parquet" && "$input" != "parquet" ]]; then
81 | reason="Non-parquet to parquet conversion not supported"
82 | elif [[ "$input" == "parquet" && "$output" != "parquet" ]]; then
83 | reason="Parquet to non-parquet conversion not supported"
84 | # Nested structure rules
85 | elif [[ "$input" == "proto" && "$output" == "env" ]]; then
86 | reason="Proto to env conversion not supported (nested structures)"
87 | elif [[ "$output" == "env" && "$input" != "env" ]]; then
88 | reason="Complex structures to env conversion not supported"
89 | fi
90 |
91 | if [[ -n "$reason" ]]; then
92 | echo "$reason"
93 | return 0
94 | fi
95 | return 1
96 | }
97 |
98 | # Test execution wrapper
99 | run_test() {
100 | local test_name="$1"
101 | local command="$2"
102 |
103 | total_tests=$((total_tests + 1))
104 | print "blue" "[$total_tests] Testing: $test_name"
105 |
106 | local exit_code=0
107 | eval "$command" &>/dev/null || exit_code=$?
108 |
109 | if [[ $exit_code -eq 0 ]]; then
110 | passed_tests=$((passed_tests + 1))
111 | print "green" " ✓ PASS"
112 | else
113 | failed_tests=$((failed_tests + 1))
114 | print "red" " ✗ FAIL: $command"
115 | print "red" " Command failed with exit code $exit_code"
116 | fi
117 | }
118 |
119 | # Progress summary
120 | print_summary() {
121 | echo
122 | print "blue" "=== Test Summary ==="
123 | print "green" "Passed: $passed_tests"
124 | print "yellow" "Skipped: $skipped_tests"
125 | print "red" "Failed: $failed_tests"
126 | print "blue" "Total: $total_tests"
127 | echo
128 | }
129 |
130 | # Main execution
131 | main() {
132 | print "blue" "Starting qq codec tests..."
133 | echo
134 |
135 | validate_prerequisites
136 |
137 | # Get test extensions, excluding shell scripts and ini files
138 | local extensions
139 | if ! extensions=$(find tests -maxdepth 1 -type f ! -name "*.sh" ! -name "*.ini" 2>/dev/null); then
140 | print "red" "Error: Failed to find test files"
141 | exit 1
142 | fi
143 |
144 | if [[ -z "$extensions" ]]; then
145 | print "yellow" "Warning: No test files found in tests/ directory"
146 | exit 0
147 | fi
148 |
149 | # Test all format conversions
150 | for input_file in $extensions; do
151 | local input_ext=""
152 | input_ext="${input_file##*.}"
153 |
154 | # Skip if we can't determine extension
155 | if [[ -z "$input_ext" || "$input_ext" == "$input_file" ]]; then
156 | continue
157 | fi
158 |
159 | print "yellow" "Testing conversions from $input_ext format..."
160 |
161 | for output_file in $extensions; do
162 | local output_ext=""
163 | output_ext="${output_file##*.}"
164 |
165 | # Skip if we can't determine extension
166 | if [[ -z "$output_ext" || "$output_ext" == "$output_file" ]]; then
167 | continue
168 | fi
169 |
170 | # Check if conversion should be skipped
171 | local skip_reason=""
172 | if skip_reason=$(should_skip_conversion "$input_ext" "$output_ext"); then
173 | skipped_tests=$((skipped_tests + 1))
174 | print "yellow" " -> Skipping $input_ext->$output_ext: $skip_reason"
175 | continue
176 | fi
177 |
178 | # Run the conversion test
179 | local test_name="$input_ext -> $output_ext"
180 | local command
181 | if [[ "$input_ext" == "parquet" || "$input_ext" == "msgpack" ]]; then
182 | # Parquet files are binary, use qq directly
183 | command="bin/qq '$input_file' | bin/qq -o '$output_ext'"
184 | else
185 | command="cat '$input_file' | grep -v '#' | bin/qq -i '$input_ext' -o '$output_ext'"
186 | fi
187 | run_test "$test_name" "$command"
188 | done
189 |
190 | # Test embedded test cases (lines with # comments) - skip for binary files
191 | if [[ "$input_ext" != "parquet" || "$input_ext" != "msgpack" ]] && grep -q "#" "$input_file" 2>/dev/null; then
192 | # Process each line with a # comment individually
193 | while IFS= read -r line; do
194 | if [[ "$line" =~ ^#[[:space:]]*(.+)$ ]]; then
195 | local case="${BASH_REMATCH[1]}"
196 | # Remove leading/trailing whitespace
197 | case=$(echo "$case" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
198 | if [[ -n "$case" ]]; then
199 | local test_name="$input_ext embedded case: $case"
200 | local command="cat '$input_file' | grep -v '^#' | bin/qq -i '$input_ext' | jq $case"
201 | run_test "$test_name" "$command"
202 | fi
203 | fi
204 | done < "$input_file"
205 | fi
206 | done
207 |
208 | # Test jq pipeline conversions (excluding codecs with constrained structures/binary formats)
209 | print "yellow" "Testing jq pipeline conversions..."
210 | local previous_ext="json"
211 | for file in $extensions; do
212 | local current_ext
213 | current_ext="${file##*.}"
214 |
215 | if [[ "$current_ext" == "csv" || "$current_ext" == "parquet" || "$current_ext" == "msgpack" ]]; then
216 | continue
217 | fi
218 |
219 | # Skip if target format doesn't support complex structures
220 | local skip_reason=""
221 | if skip_reason=$(should_skip_conversion "$current_ext" "$previous_ext"); then
222 | skipped_tests=$((skipped_tests + 1))
223 | continue
224 | fi
225 |
226 | local test_name="jq pipeline: $current_ext -> $previous_ext"
227 | local command="cat '$file' | grep -v '^#' | bin/qq -i '$current_ext' | jq . | bin/qq -o '$previous_ext'"
228 | run_test "$test_name" "$command"
229 |
230 | previous_ext="$current_ext"
231 | done
232 |
233 | print_summary
234 |
235 | if [[ $failed_tests -eq 0 ]]; then
236 | print "green" "All tests passed! 🎉"
237 | fi
238 | }
239 |
240 | main "$@"
241 |
242 |
--------------------------------------------------------------------------------
/codec/parquet/parquet_test.go:
--------------------------------------------------------------------------------
1 | package parquet
2 |
3 | import (
4 | "testing"
5 | )
6 |
7 | func TestBasicParquetMarshalUnmarshal(t *testing.T) {
8 | testData := []map[string]any{
9 | {
10 | "ID": 1,
11 | "Name": "Alice",
12 | "Age": 30,
13 | "Active": true,
14 | "Score": 95.5,
15 | "Department": "Engineering",
16 | },
17 | {
18 | "ID": 2,
19 | "Name": "Bob",
20 | "Age": 25,
21 | "Active": false,
22 | "Score": 87.2,
23 | "Department": "Sales",
24 | },
25 | {
26 | "ID": 3,
27 | "Name": "Charlie",
28 | "Age": 35,
29 | "Active": true,
30 | "Score": 92.0,
31 | "Department": "Engineering",
32 | },
33 | }
34 |
35 | codec := &Codec{}
36 |
37 | // Test marshaling
38 | data, err := codec.Marshal(testData)
39 | if err != nil {
40 | t.Fatalf("Failed to marshal parquet data: %v", err)
41 | }
42 |
43 | if len(data) == 0 {
44 | t.Fatal("Marshaled data is empty")
45 | }
46 |
47 | // Test unmarshaling
48 | var result []map[string]any
49 | err = codec.Unmarshal(data, &result)
50 | if err != nil {
51 | t.Fatalf("Failed to unmarshal parquet data: %v", err)
52 | }
53 |
54 | // Verify length
55 | if len(result) != 3 {
56 | t.Fatalf("Expected 3 records, got %d", len(result))
57 | }
58 |
59 | // Verify first record structure
60 | first := result[0]
61 | if first["ID"] != "1" {
62 | t.Errorf("Expected ID '1', got %v", first["ID"])
63 | }
64 | if first["Name"] != "Alice" {
65 | t.Errorf("Expected Name 'Alice', got %v", first["Name"])
66 | }
67 | if first["Age"] != "30" {
68 | t.Errorf("Expected Age '30', got %v", first["Age"])
69 | }
70 | if first["Active"] != "true" {
71 | t.Errorf("Expected Active 'true', got %v", first["Active"])
72 | }
73 | if first["Score"] != "95.5" {
74 | t.Errorf("Expected Score '95.5', got %v", first["Score"])
75 | }
76 | if first["Department"] != "Engineering" {
77 | t.Errorf("Expected Department 'Engineering', got %v", first["Department"])
78 | }
79 | }
80 |
81 | func TestEmptyDataHandling(t *testing.T) {
82 | codec := &Codec{}
83 |
84 | // Test empty slice
85 | emptyData := []map[string]any{}
86 | _, err := codec.Marshal(emptyData)
87 | if err == nil {
88 | t.Error("Expected error for empty data, got nil")
89 | }
90 | }
91 |
92 | func TestNilValueHandling(t *testing.T) {
93 | testData := []map[string]any{
94 | {
95 | "ID": 1,
96 | "Name": "Alice",
97 | "OptionalField": nil,
98 | "Department": "Engineering",
99 | },
100 | {
101 | "ID": 2,
102 | "Name": "Bob",
103 | "OptionalField": "Some Value",
104 | "Department": "Sales",
105 | },
106 | }
107 |
108 | codec := &Codec{}
109 |
110 | // Test marshaling with nil values
111 | data, err := codec.Marshal(testData)
112 | if err != nil {
113 | t.Fatalf("Failed to marshal parquet data with nil values: %v", err)
114 | }
115 |
116 | // Test unmarshaling
117 | var result []map[string]any
118 | err = codec.Unmarshal(data, &result)
119 | if err != nil {
120 | t.Fatalf("Failed to unmarshal parquet data with nil values: %v", err)
121 | }
122 |
123 | // Verify length
124 | if len(result) != 2 {
125 | t.Fatalf("Expected 2 records, got %d", len(result))
126 | }
127 |
128 | // The nil value should be handled (converted to null string or similar)
129 | first := result[0]
130 | if first["ID"] != "1" {
131 | t.Errorf("Expected ID '1', got %v", first["ID"])
132 | }
133 |
134 | // Second record should have the value
135 | second := result[1]
136 | if second["OptionalField"] != "Some Value" {
137 | t.Errorf("Expected OptionalField 'Some Value', got %v", second["OptionalField"])
138 | }
139 | }
140 |
141 | func TestMissingFieldsHandling(t *testing.T) {
142 | testData := []map[string]any{
143 | {
144 | "ID": 1,
145 | "Name": "Alice",
146 | "Age": 30,
147 | },
148 | {
149 | "ID": 2,
150 | "Name": "Bob",
151 | "Department": "Sales", // Missing Age field
152 | },
153 | {
154 | "ID": 3,
155 | "Name": "Charlie",
156 | "Age": 35,
157 | "Department": "Engineering", // Has all fields
158 | },
159 | }
160 |
161 | codec := &Codec{}
162 |
163 | // Test marshaling with inconsistent fields
164 | data, err := codec.Marshal(testData)
165 | if err != nil {
166 | t.Fatalf("Failed to marshal parquet data with missing fields: %v", err)
167 | }
168 |
169 | // Test unmarshaling
170 | var result []map[string]any
171 | err = codec.Unmarshal(data, &result)
172 | if err != nil {
173 | t.Fatalf("Failed to unmarshal parquet data with missing fields: %v", err)
174 | }
175 |
176 | // Verify length
177 | if len(result) != 3 {
178 | t.Fatalf("Expected 3 records, got %d", len(result))
179 | }
180 |
181 | // All records should have the same field structure (with null values for missing fields)
182 | for i, record := range result {
183 | if record["ID"] == nil {
184 | t.Errorf("Record %d missing ID field", i)
185 | }
186 | if record["Name"] == nil {
187 | t.Errorf("Record %d missing Name field", i)
188 | }
189 | // Age and Department might be null for some records, which is fine
190 | }
191 | }
192 |
193 | func TestLargeDataSet(t *testing.T) {
194 | // Create a larger dataset to test performance and memory handling
195 | testData := make([]map[string]any, 1000)
196 | for i := 0; i < 1000; i++ {
197 | testData[i] = map[string]any{
198 | "ID": i + 1,
199 | "Name": "User" + string(rune(i%26+65)),
200 | "Value": float64(i) * 1.5,
201 | "Active": i%2 == 0,
202 | "Category": "Category" + string(rune(i%5+65)),
203 | }
204 | }
205 |
206 | codec := &Codec{}
207 |
208 | // Test marshaling large dataset
209 | data, err := codec.Marshal(testData)
210 | if err != nil {
211 | t.Fatalf("Failed to marshal large parquet dataset: %v", err)
212 | }
213 |
214 | if len(data) == 0 {
215 | t.Fatal("Marshaled large dataset is empty")
216 | }
217 |
218 | // Test unmarshaling large dataset
219 | var result []map[string]any
220 | err = codec.Unmarshal(data, &result)
221 | if err != nil {
222 | t.Fatalf("Failed to unmarshal large parquet dataset: %v", err)
223 | }
224 |
225 | // Verify length
226 | if len(result) != 1000 {
227 | t.Fatalf("Expected 1000 records, got %d", len(result))
228 | }
229 |
230 | // Spot check a few records
231 | if result[0]["ID"] != "1" {
232 | t.Errorf("First record ID incorrect: %v", result[0]["ID"])
233 | }
234 | if result[999]["ID"] != "1000" {
235 | t.Errorf("Last record ID incorrect: %v", result[999]["ID"])
236 | }
237 | }
238 |
239 | func TestInvalidInputTypes(t *testing.T) {
240 | codec := &Codec{}
241 |
242 | // Test non-slice input
243 | invalidData := map[string]any{"key": "value"}
244 | _, err := codec.Marshal(invalidData)
245 | if err == nil {
246 | t.Error("Expected error for non-slice input, got nil")
247 | }
248 |
249 | // Test slice of non-map elements
250 | invalidSlice := []string{"item1", "item2"}
251 | _, err = codec.Marshal(invalidSlice)
252 | if err == nil {
253 | t.Error("Expected error for slice of non-map elements, got nil")
254 | }
255 | }
256 |
257 | func TestRoundTripConsistency(t *testing.T) {
258 | originalData := []map[string]any{
259 | {
260 | "StringField": "test string",
261 | "NumberField": 42,
262 | "FloatField": 3.14159,
263 | "BoolField": true,
264 | "NullField": nil,
265 | },
266 | {
267 | "StringField": "another string",
268 | "NumberField": -17,
269 | "FloatField": -2.718,
270 | "BoolField": false,
271 | "NullField": nil,
272 | },
273 | }
274 |
275 | codec := &Codec{}
276 |
277 | // Marshal original data
278 | data, err := codec.Marshal(originalData)
279 | if err != nil {
280 | t.Fatalf("Failed to marshal data: %v", err)
281 | }
282 |
283 | // Unmarshal to get result
284 | var result1 []map[string]any
285 | err = codec.Unmarshal(data, &result1)
286 | if err != nil {
287 | t.Fatalf("Failed to unmarshal data: %v", err)
288 | }
289 |
290 | // Marshal the result again
291 | data2, err := codec.Marshal(result1)
292 | if err != nil {
293 | t.Fatalf("Failed to marshal result data: %v", err)
294 | }
295 |
296 | // Unmarshal again
297 | var result2 []map[string]any
298 | err = codec.Unmarshal(data2, &result2)
299 | if err != nil {
300 | t.Fatalf("Failed to unmarshal data second time: %v", err)
301 | }
302 |
303 | // Results should be consistent
304 | if len(result1) != len(result2) {
305 | t.Fatalf("Round-trip changed length: %d vs %d", len(result1), len(result2))
306 | }
307 |
308 | for i := range result1 {
309 | if len(result1[i]) != len(result2[i]) {
310 | t.Errorf("Record %d field count changed: %d vs %d", i, len(result1[i]), len(result2[i]))
311 | }
312 |
313 | for key := range result1[i] {
314 | if result1[i][key] != result2[i][key] {
315 | t.Errorf("Record %d field %s changed: %v vs %v", i, key, result1[i][key], result2[i][key])
316 | }
317 | }
318 | }
319 | }
320 |
--------------------------------------------------------------------------------
/codec/xml/xml_test.go:
--------------------------------------------------------------------------------
1 | package xml
2 |
3 | import (
4 | "strings"
5 | "testing"
6 | )
7 |
8 | func TestBasicXMLMarshalUnmarshal(t *testing.T) {
9 | testData := map[string]any{
10 | "name": "Alice",
11 | "age": 30,
12 | "active": true,
13 | "score": 95.5,
14 | }
15 |
16 | codec := &Codec{}
17 |
18 | // Test marshaling
19 | data, err := codec.Marshal(testData)
20 | if err != nil {
21 | t.Fatalf("Failed to marshal XML data: %v", err)
22 | }
23 |
24 | if len(data) == 0 {
25 | t.Fatal("Marshaled data is empty")
26 | }
27 |
28 | // Check that it contains XML structure
29 | xmlStr := string(data)
30 | if !strings.Contains(xmlStr, "") || !strings.Contains(xmlStr, "") {
31 | t.Error("XML output missing doc element")
32 | }
33 |
34 | // Test unmarshaling
35 | var result map[string]any
36 | err = codec.Unmarshal(data, &result)
37 | if err != nil {
38 | t.Fatalf("Failed to unmarshal XML data: %v", err)
39 | }
40 |
41 | // The XML codec wraps content in a doc element and parses values by type
42 | doc := result["doc"].(map[string]any)
43 | if doc["name"] != "Alice" {
44 | t.Errorf("Expected name 'Alice', got %v", doc["name"])
45 | }
46 | if doc["age"] != 30 {
47 | t.Errorf("Expected age 30, got %v", doc["age"])
48 | }
49 | if doc["active"] != true {
50 | t.Errorf("Expected active true, got %v", doc["active"])
51 | }
52 | if doc["score"] != 95.5 {
53 | t.Errorf("Expected score 95.5, got %v", doc["score"])
54 | }
55 | }
56 |
57 | func TestXMLArrayMarshalUnmarshal(t *testing.T) {
58 | testData := []any{
59 | map[string]any{
60 | "id": 1,
61 | "name": "Alice",
62 | },
63 | map[string]any{
64 | "id": 2,
65 | "name": "Bob",
66 | },
67 | }
68 |
69 | codec := &Codec{}
70 |
71 | // Test marshaling
72 | data, err := codec.Marshal(testData)
73 | if err != nil {
74 | t.Fatalf("Failed to marshal XML array: %v", err)
75 | }
76 |
77 | // Check XML structure
78 | xmlStr := string(data)
79 | if !strings.Contains(xmlStr, "") {
80 | t.Error("XML array output missing root element")
81 | }
82 |
83 | // Test unmarshaling
84 | var result map[string]any
85 | err = codec.Unmarshal(data, &result)
86 | if err != nil {
87 | t.Fatalf("Failed to unmarshal XML array: %v", err)
88 | }
89 |
90 | // The array items should be in the doc as separate root elements
91 | doc := result["doc"].(map[string]any)
92 | rootItems := doc["root"].([]any)
93 |
94 | // Verify length
95 | if len(rootItems) != 2 {
96 | t.Fatalf("Expected 2 records, got %d", len(rootItems))
97 | }
98 |
99 | // Verify first record - values are parsed by type
100 | firstItem := rootItems[0].(map[string]any)
101 | if firstItem["id"] != 1 {
102 | t.Errorf("Expected id 1, got %v", firstItem["id"])
103 | }
104 | if firstItem["name"] != "Alice" {
105 | t.Errorf("Expected name 'Alice', got %v", firstItem["name"])
106 | }
107 | }
108 |
109 | func TestXMLWithSpecialCharacters(t *testing.T) {
110 | testData := map[string]any{
111 | "description": "Text with simple content",
112 | "code": "if x then return true",
113 | "unicode": "Hello 世界",
114 | }
115 |
116 | codec := &Codec{}
117 |
118 | // Test marshaling
119 | data, err := codec.Marshal(testData)
120 | if err != nil {
121 | t.Fatalf("Failed to marshal XML with special characters: %v", err)
122 | }
123 |
124 | // Test unmarshaling
125 | var result map[string]any
126 | err = codec.Unmarshal(data, &result)
127 | if err != nil {
128 | t.Fatalf("Failed to unmarshal XML with special characters: %v", err)
129 | }
130 |
131 | // Verify content is preserved
132 | doc := result["doc"].(map[string]any)
133 | if doc["description"] != "Text with simple content" {
134 | t.Errorf("Description not preserved: %v", doc["description"])
135 | }
136 | if doc["code"] != "if x then return true" {
137 | t.Errorf("Code not preserved: %v", doc["code"])
138 | }
139 | if doc["unicode"] != "Hello 世界" {
140 | t.Errorf("Unicode not preserved: %v", doc["unicode"])
141 | }
142 | }
143 |
144 | func TestXMLNestedStructure(t *testing.T) {
145 | testData := map[string]any{
146 | "user": map[string]any{
147 | "personal": map[string]any{
148 | "name": "Alice",
149 | "age": 30,
150 | },
151 | "professional": map[string]any{
152 | "title": "Engineer",
153 | "department": "Technology",
154 | },
155 | },
156 | }
157 |
158 | codec := &Codec{}
159 |
160 | // Test marshaling
161 | data, err := codec.Marshal(testData)
162 | if err != nil {
163 | t.Fatalf("Failed to marshal nested XML: %v", err)
164 | }
165 |
166 | // Test unmarshaling
167 | var result map[string]any
168 | err = codec.Unmarshal(data, &result)
169 | if err != nil {
170 | t.Fatalf("Failed to unmarshal nested XML: %v", err)
171 | }
172 |
173 | // Navigate nested structure - no doc wrapper for this structure
174 | user := result["user"].(map[string]any)
175 | personal := user["personal"].(map[string]any)
176 | professional := user["professional"].(map[string]any)
177 |
178 | if personal["name"] != "Alice" {
179 | t.Errorf("Expected nested name 'Alice', got %v", personal["name"])
180 | }
181 | if personal["age"] != 30 {
182 | t.Errorf("Expected nested age 30, got %v", personal["age"])
183 | }
184 | if professional["title"] != "Engineer" {
185 | t.Errorf("Expected title 'Engineer', got %v", professional["title"])
186 | }
187 | }
188 |
189 | func TestXMLEmptyAndNullValues(t *testing.T) {
190 | testData := map[string]any{
191 | "name": "Alice",
192 | "middle_name": "",
193 | "optional": nil,
194 | "empty_object": map[string]any{},
195 | }
196 |
197 | codec := &Codec{}
198 |
199 | // Test marshaling
200 | data, err := codec.Marshal(testData)
201 | if err != nil {
202 | t.Fatalf("Failed to marshal XML with empty/null values: %v", err)
203 | }
204 |
205 | // Test unmarshaling
206 | var result map[string]any
207 | err = codec.Unmarshal(data, &result)
208 | if err != nil {
209 | t.Fatalf("Failed to unmarshal XML with empty/null values: %v", err)
210 | }
211 |
212 | // Verify handling of empty and null values
213 | doc := result["doc"].(map[string]any)
214 | if doc["name"] != "Alice" {
215 | t.Errorf("Expected name 'Alice', got %v", doc["name"])
216 | }
217 | // Empty strings and nil values may not be preserved in XML
218 | if val, exists := doc["middle_name"]; exists && val != "" {
219 | t.Errorf("Expected empty or missing middle_name, got %v", val)
220 | }
221 | }
222 |
223 | func TestXMLRoundTrip(t *testing.T) {
224 | originalData := map[string]any{
225 | "string": "test value",
226 | "number": 42,
227 | "boolean": true,
228 | "nested": map[string]any{
229 | "inner": "nested value",
230 | },
231 | }
232 |
233 | codec := &Codec{}
234 |
235 | // First marshal
236 | data1, err := codec.Marshal(originalData)
237 | if err != nil {
238 | t.Fatalf("Failed first marshal: %v", err)
239 | }
240 |
241 | // First unmarshal
242 | var result1 map[string]any
243 | err = codec.Unmarshal(data1, &result1)
244 | if err != nil {
245 | t.Fatalf("Failed first unmarshal: %v", err)
246 | }
247 |
248 | // Second marshal
249 | data2, err := codec.Marshal(result1)
250 | if err != nil {
251 | t.Fatalf("Failed second marshal: %v", err)
252 | }
253 |
254 | // Second unmarshal
255 | var result2 map[string]any
256 | err = codec.Unmarshal(data2, &result2)
257 | if err != nil {
258 | t.Fatalf("Failed second unmarshal: %v", err)
259 | }
260 |
261 | // Compare values (XML codec parses values by type)
262 | doc2 := result2["doc"].(map[string]any)
263 | if doc2["string"] != "test value" {
264 | t.Errorf("String field changed: %v", doc2["string"])
265 | }
266 | if doc2["number"] != 42 {
267 | t.Errorf("Number field changed: %v", doc2["number"])
268 | }
269 | if doc2["boolean"] != true {
270 | t.Errorf("Boolean field changed: %v", doc2["boolean"])
271 | }
272 |
273 | // Check nested structure
274 | if nested2, ok := doc2["nested"].(map[string]any); ok {
275 | if nested2["inner"] != "nested value" {
276 | t.Errorf("Nested field changed: %v", nested2["inner"])
277 | }
278 | } else {
279 | t.Errorf("Nested structure not preserved: %v", doc2["nested"])
280 | }
281 | }
282 |
283 | func TestXMLValidFormat(t *testing.T) {
284 | testData := map[string]any{
285 | "item1": "value1",
286 | "item2": "value2",
287 | }
288 |
289 | codec := &Codec{}
290 |
291 | // Test marshaling
292 | data, err := codec.Marshal(testData)
293 | if err != nil {
294 | t.Fatalf("Failed to marshal XML: %v", err)
295 | }
296 |
297 | xmlStr := string(data)
298 |
299 | // Basic XML validation checks - XML declaration is optional
300 | trimmed := strings.TrimSpace(xmlStr)
301 | if !strings.HasPrefix(trimmed, "") {
302 | t.Error("XML missing XML declaration or doc element")
303 | }
304 |
305 | if !strings.Contains(xmlStr, "") {
306 | t.Error("XML missing doc opening tag")
307 | }
308 |
309 | if !strings.Contains(xmlStr, "") {
310 | t.Error("XML missing doc closing tag")
311 | }
312 |
313 | // Count opening and closing tags for item1
314 | openCount := strings.Count(xmlStr, "")
315 | closeCount := strings.Count(xmlStr, "")
316 | if openCount != closeCount {
317 | t.Errorf("Mismatched item1 tags: %d open, %d close", openCount, closeCount)
318 | }
319 | }
320 |
--------------------------------------------------------------------------------
/codec/yaml/yaml_test.go:
--------------------------------------------------------------------------------
1 | package yaml
2 |
3 | import (
4 | "testing"
5 | )
6 |
7 | // Helper function to check numeric values that might be int, int64, uint64, or float64
8 | func assertNumericEqual(t *testing.T, actual any, expected float64, fieldName string) {
9 | switch v := actual.(type) {
10 | case int:
11 | if float64(v) != expected {
12 | t.Errorf("Expected %s %.0f, got %v", fieldName, expected, v)
13 | }
14 | case int64:
15 | if float64(v) != expected {
16 | t.Errorf("Expected %s %.0f, got %v", fieldName, expected, v)
17 | }
18 | case uint64:
19 | if float64(v) != expected {
20 | t.Errorf("Expected %s %.0f, got %v", fieldName, expected, v)
21 | }
22 | case float64:
23 | if v != expected {
24 | t.Errorf("Expected %s %v, got %v", fieldName, expected, v)
25 | }
26 | default:
27 | t.Errorf("Expected %s as number, got %v (type: %T)", fieldName, v, v)
28 | }
29 | }
30 |
31 | func TestBasicYAMLMarshalUnmarshal(t *testing.T) {
32 | testData := map[string]any{
33 | "name": "Alice",
34 | "age": 30,
35 | "active": true,
36 | "score": 95.5,
37 | "tags": []string{"engineer", "golang"},
38 | "metadata": map[string]any{
39 | "department": "Engineering",
40 | "level": "Senior",
41 | },
42 | }
43 |
44 | codec := &Codec{}
45 |
46 | // Test marshaling
47 | data, err := codec.Marshal(testData)
48 | if err != nil {
49 | t.Fatalf("Failed to marshal YAML data: %v", err)
50 | }
51 |
52 | if len(data) == 0 {
53 | t.Fatal("Marshaled data is empty")
54 | }
55 |
56 | // Test unmarshaling
57 | var result map[string]any
58 | err = codec.Unmarshal(data, &result)
59 | if err != nil {
60 | t.Fatalf("Failed to unmarshal YAML data: %v", err)
61 | }
62 |
63 | // Verify basic fields
64 | // Note: YAML may preserve int types, unlike pure JSON round-trip
65 | if result["name"] != "Alice" {
66 | t.Errorf("Expected name 'Alice', got %v", result["name"])
67 | }
68 | assertNumericEqual(t, result["age"], 30, "age")
69 | if result["active"] != true {
70 | t.Errorf("Expected active true, got %v", result["active"])
71 | }
72 | if result["score"] != 95.5 {
73 | t.Errorf("Expected score 95.5, got %v", result["score"])
74 | }
75 |
76 | // Verify array
77 | tags := result["tags"].([]any)
78 | if len(tags) != 2 {
79 | t.Errorf("Expected 2 tags, got %d", len(tags))
80 | }
81 | if tags[0] != "engineer" {
82 | t.Errorf("Expected first tag 'engineer', got %v", tags[0])
83 | }
84 |
85 | // Verify nested object
86 | metadata := result["metadata"].(map[string]any)
87 | if metadata["department"] != "Engineering" {
88 | t.Errorf("Expected department 'Engineering', got %v", metadata["department"])
89 | }
90 | }
91 |
92 | func TestYAMLArrayMarshalUnmarshal(t *testing.T) {
93 | testData := []map[string]any{
94 | {
95 | "id": 1,
96 | "name": "Alice",
97 | },
98 | {
99 | "id": 2,
100 | "name": "Bob",
101 | },
102 | }
103 |
104 | codec := &Codec{}
105 |
106 | // Test marshaling
107 | data, err := codec.Marshal(testData)
108 | if err != nil {
109 | t.Fatalf("Failed to marshal YAML array: %v", err)
110 | }
111 |
112 | // Test unmarshaling
113 | var result []map[string]any
114 | err = codec.Unmarshal(data, &result)
115 | if err != nil {
116 | t.Fatalf("Failed to unmarshal YAML array: %v", err)
117 | }
118 |
119 | // Verify length
120 | if len(result) != 2 {
121 | t.Fatalf("Expected 2 records, got %d", len(result))
122 | }
123 |
124 | // Verify first record
125 | assertNumericEqual(t, result[0]["id"], 1, "id")
126 | if result[0]["name"] != "Alice" {
127 | t.Errorf("Expected name 'Alice', got %v", result[0]["name"])
128 | }
129 | }
130 |
131 | func TestYAMLWithNullValues(t *testing.T) {
132 | testData := map[string]any{
133 | "name": "Alice",
134 | "middle_name": nil,
135 | "age": 30,
136 | "optional": nil,
137 | }
138 |
139 | codec := &Codec{}
140 |
141 | // Test marshaling
142 | data, err := codec.Marshal(testData)
143 | if err != nil {
144 | t.Fatalf("Failed to marshal YAML with null values: %v", err)
145 | }
146 |
147 | // Test unmarshaling
148 | var result map[string]any
149 | err = codec.Unmarshal(data, &result)
150 | if err != nil {
151 | t.Fatalf("Failed to unmarshal YAML with null values: %v", err)
152 | }
153 |
154 | // Verify null values are preserved
155 | if result["middle_name"] != nil {
156 | t.Errorf("Expected middle_name nil, got %v", result["middle_name"])
157 | }
158 | if result["optional"] != nil {
159 | t.Errorf("Expected optional nil, got %v", result["optional"])
160 | }
161 | if result["name"] != "Alice" {
162 | t.Errorf("Expected name 'Alice', got %v", result["name"])
163 | }
164 | }
165 |
166 | func TestYAMLMultilineStrings(t *testing.T) {
167 | testData := map[string]any{
168 | "description": "This is a\nmultiline\nstring",
169 | "code": "func main() {\n\tfmt.Println(\"Hello\")\n}",
170 | "simple": "single line",
171 | }
172 |
173 | codec := &Codec{}
174 |
175 | // Test marshaling
176 | data, err := codec.Marshal(testData)
177 | if err != nil {
178 | t.Fatalf("Failed to marshal YAML with multiline strings: %v", err)
179 | }
180 |
181 | // Test unmarshaling
182 | var result map[string]any
183 | err = codec.Unmarshal(data, &result)
184 | if err != nil {
185 | t.Fatalf("Failed to unmarshal YAML with multiline strings: %v", err)
186 | }
187 |
188 | // Verify multiline strings are preserved
189 | if result["description"] != "This is a\nmultiline\nstring" {
190 | t.Errorf("Multiline string not preserved: %v", result["description"])
191 | }
192 | if result["simple"] != "single line" {
193 | t.Errorf("Simple string not preserved: %v", result["simple"])
194 | }
195 | }
196 |
197 | func TestYAMLBooleans(t *testing.T) {
198 | testData := map[string]any{
199 | "true_bool": true,
200 | "false_bool": false,
201 | "true_str": "true",
202 | "false_str": "false",
203 | "yes_str": "yes",
204 | "no_str": "no",
205 | }
206 |
207 | codec := &Codec{}
208 |
209 | // Test marshaling
210 | data, err := codec.Marshal(testData)
211 | if err != nil {
212 | t.Fatalf("Failed to marshal YAML with booleans: %v", err)
213 | }
214 |
215 | // Test unmarshaling
216 | var result map[string]any
217 | err = codec.Unmarshal(data, &result)
218 | if err != nil {
219 | t.Fatalf("Failed to unmarshal YAML with booleans: %v", err)
220 | }
221 |
222 | // Verify boolean values
223 | if result["true_bool"] != true {
224 | t.Errorf("Expected true_bool true, got %v", result["true_bool"])
225 | }
226 | if result["false_bool"] != false {
227 | t.Errorf("Expected false_bool false, got %v", result["false_bool"])
228 | }
229 |
230 | // String representations should remain as strings
231 | if result["true_str"] != "true" {
232 | t.Errorf("Expected true_str 'true', got %v", result["true_str"])
233 | }
234 | }
235 |
236 | func TestYAMLNumbers(t *testing.T) {
237 | testData := map[string]any{
238 | "int": 42,
239 | "negative_int": -17,
240 | "float": 3.14159,
241 | "negative_float": -2.718,
242 | "zero": 0,
243 | "scientific": 1.23e10,
244 | }
245 |
246 | codec := &Codec{}
247 |
248 | // Test marshaling
249 | data, err := codec.Marshal(testData)
250 | if err != nil {
251 | t.Fatalf("Failed to marshal YAML with numbers: %v", err)
252 | }
253 |
254 | // Test unmarshaling
255 | var result map[string]any
256 | err = codec.Unmarshal(data, &result)
257 | if err != nil {
258 | t.Fatalf("Failed to unmarshal YAML with numbers: %v", err)
259 | }
260 |
261 | // Verify numbers (YAML may preserve int types)
262 | assertNumericEqual(t, result["int"], 42, "int")
263 | assertNumericEqual(t, result["negative_int"], -17, "negative_int")
264 | assertNumericEqual(t, result["float"], 3.14159, "float")
265 | assertNumericEqual(t, result["zero"], 0, "zero")
266 | }
267 |
268 | func TestYAMLDeepNesting(t *testing.T) {
269 | testData := map[string]any{
270 | "level1": map[string]any{
271 | "level2": map[string]any{
272 | "level3": map[string]any{
273 | "level4": "deep value",
274 | "array": []any{
275 | map[string]any{"item": 1},
276 | map[string]any{"item": 2},
277 | },
278 | },
279 | },
280 | },
281 | }
282 |
283 | codec := &Codec{}
284 |
285 | // Test marshaling
286 | data, err := codec.Marshal(testData)
287 | if err != nil {
288 | t.Fatalf("Failed to marshal deeply nested YAML: %v", err)
289 | }
290 |
291 | // Test unmarshaling
292 | var result map[string]any
293 | err = codec.Unmarshal(data, &result)
294 | if err != nil {
295 | t.Fatalf("Failed to unmarshal deeply nested YAML: %v", err)
296 | }
297 |
298 | // Navigate to deep value
299 | level1 := result["level1"].(map[string]any)
300 | level2 := level1["level2"].(map[string]any)
301 | level3 := level2["level3"].(map[string]any)
302 | deepValue := level3["level4"]
303 |
304 | if deepValue != "deep value" {
305 | t.Errorf("Expected 'deep value', got %v", deepValue)
306 | }
307 |
308 | // Check nested array
309 | array := level3["array"].([]any)
310 | if len(array) != 2 {
311 | t.Errorf("Expected 2 array items, got %d", len(array))
312 | }
313 |
314 | firstItem := array[0].(map[string]any)
315 | assertNumericEqual(t, firstItem["item"], 1, "item")
316 | }
317 |
318 | func TestYAMLRoundTrip(t *testing.T) {
319 | originalData := map[string]any{
320 | "string": "test",
321 | "number": 42,
322 | "float": 3.14,
323 | "boolean": true,
324 | "null": nil,
325 | "array": []any{1, 2, 3},
326 | "object": map[string]any{
327 | "nested": "value",
328 | },
329 | }
330 |
331 | codec := &Codec{}
332 |
333 | // First marshal
334 | data1, err := codec.Marshal(originalData)
335 | if err != nil {
336 | t.Fatalf("Failed first marshal: %v", err)
337 | }
338 |
339 | // First unmarshal
340 | var result1 map[string]any
341 | err = codec.Unmarshal(data1, &result1)
342 | if err != nil {
343 | t.Fatalf("Failed first unmarshal: %v", err)
344 | }
345 |
346 | // Second marshal
347 | data2, err := codec.Marshal(result1)
348 | if err != nil {
349 | t.Fatalf("Failed second marshal: %v", err)
350 | }
351 |
352 | // Second unmarshal
353 | var result2 map[string]any
354 | err = codec.Unmarshal(data2, &result2)
355 | if err != nil {
356 | t.Fatalf("Failed second unmarshal: %v", err)
357 | }
358 |
359 | // Compare key values
360 | if result2["string"] != "test" {
361 | t.Errorf("String field changed: %v", result2["string"])
362 | }
363 | assertNumericEqual(t, result2["number"], 42, "number")
364 | if result2["boolean"] != true {
365 | t.Errorf("Boolean field changed: %v", result2["boolean"])
366 | }
367 | if result2["null"] != nil {
368 | t.Errorf("Null field changed: %v", result2["null"])
369 | }
370 | }
371 |
--------------------------------------------------------------------------------
/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/textarea"
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 | // Enhanced color scheme
19 | focusedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6B9D")).Bold(true)
20 | cursorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFF00")).Background(lipgloss.Color("#FF6B9D")).Bold(true)
21 | previewStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFD700")).Background(lipgloss.Color("#2D2D2D")).Italic(true).Padding(0, 1)
22 | outputStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#50FA7B"))
23 | headerStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#8BE9FD")).Bold(true).Underline(true)
24 | legendStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#6272A4")).Italic(true)
25 | errorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FF5555")).Background(lipgloss.Color("#44475A")).Bold(true).Padding(0, 1)
26 | borderStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#44475A"))
27 | textAreaStyle = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color("#FF6B9D")).Padding(0, 1)
28 | viewportStyle = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color("#50FA7B")).Padding(1)
29 | successStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#50FA7B")).Bold(true)
30 | warningStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#F1FA8C")).Bold(true)
31 | )
32 |
33 | type model struct {
34 | textArea textarea.Model
35 | jsonInput string
36 | jqOutput string
37 | lastOutput string
38 | currentIndex int
39 | showingPreview bool
40 | jqOptions []string
41 | suggestedValue string
42 | jsonObj any
43 | viewport viewport.Model
44 | gracefulExit bool
45 | }
46 |
47 | func newModel(data string) model {
48 | m := model{
49 | viewport: viewport.New(0, 0),
50 | }
51 |
52 | t := textarea.New()
53 | t.Cursor.Style = cursorStyle
54 | t.Cursor.Blink = true
55 | t.Placeholder = "Enter jq filter (try '.' to start)"
56 | t.SetValue(".")
57 | t.Focus()
58 | t.SetWidth(80)
59 | t.SetHeight(4)
60 | t.CharLimit = 0
61 | t.ShowLineNumbers = true
62 | t.KeyMap.InsertNewline.SetEnabled(true)
63 | // Enhanced textarea styling
64 | t.FocusedStyle.CursorLine = lipgloss.NewStyle().Background(lipgloss.Color("#44475A"))
65 | t.FocusedStyle.Base = textAreaStyle
66 | t.BlurredStyle.Base = textAreaStyle.BorderForeground(lipgloss.Color("#6272A4"))
67 | m.textArea = t
68 | m.jsonInput = string(data)
69 | m.jqOptions = generateJqOptions(m.jsonInput)
70 |
71 | m.runJqFilter()
72 | m.jsonObj, _ = jsonStrToInterface(m.jsonInput)
73 |
74 | return m
75 | }
76 |
77 | func generateJqOptions(jsonStr string) []string {
78 | var jsonData any
79 | err := json.Unmarshal([]byte(jsonStr), &jsonData)
80 | if err != nil {
81 | return []string{"."}
82 | }
83 |
84 | options := make(map[string]struct{})
85 | extractPaths(jsonData, "", options)
86 |
87 | // Convert map to slice
88 | var result []string
89 | for option := range options {
90 | result = append(result, option)
91 | }
92 | return result
93 | }
94 |
95 | func extractPaths(data any, prefix string, options map[string]struct{}) {
96 | switch v := data.(type) {
97 | case map[string]any:
98 | for key, value := range v {
99 | newPrefix := prefix + "." + key
100 | options[newPrefix] = struct{}{}
101 | extractPaths(value, newPrefix, options)
102 | }
103 | case []any:
104 | for i, item := range v {
105 | newPrefix := fmt.Sprintf("%s[%d]", prefix, i)
106 | options[newPrefix] = struct{}{}
107 | extractPaths(item, newPrefix, options)
108 | }
109 | }
110 | }
111 |
112 | func (m model) Init() tea.Cmd {
113 | return tea.EnterAltScreen
114 | }
115 |
116 | func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
117 | switch msg := msg.(type) {
118 | case tea.WindowSizeMsg:
119 | // Calculate dynamic header height based on actual content
120 | headerLines := 4 // Header title + newline + border + 2 newlines
121 | textAreaLines := m.textArea.Height()
122 | legendLines := 5 // 2 newlines + legend + newline + border + 2 newlines
123 | previewLines := 0
124 | if m.showingPreview && m.suggestedValue != "" {
125 | previewLines = 2 // newline + preview
126 | }
127 |
128 | headerHeight := headerLines + textAreaLines + legendLines + previewLines
129 | availableHeight := msg.Height - headerHeight
130 | if availableHeight < 3 {
131 | availableHeight = 3 // Minimum viewport height
132 | }
133 |
134 | m.viewport.Width = msg.Width
135 | m.viewport.Height = availableHeight
136 | m.textArea.SetWidth(msg.Width - 4)
137 | m.updateViewportContent()
138 | return m, nil
139 |
140 | case tea.KeyMsg:
141 | switch {
142 | case msg.String() == "ctrl+c" || msg.String() == "esc":
143 | // Try to run current query if it's valid, otherwise just exit
144 | if m.isValidQuery() {
145 | m.gracefulExit = true
146 | m.jsonObj, _ = jsonStrToInterface(m.jsonInput)
147 | }
148 | return m, tea.Quit
149 |
150 | // Suggest next jq option
151 | case msg.String() == "tab":
152 | if !m.showingPreview {
153 | m.showingPreview = true
154 | m.currentIndex = 0
155 | } else {
156 | m.currentIndex = (m.currentIndex + 1) % len(m.jqOptions)
157 | }
158 | m.suggestedValue = m.jqOptions[m.currentIndex]
159 | return m, nil
160 |
161 | case msg.String() == "enter":
162 | if m.showingPreview {
163 | m.textArea.SetValue(m.suggestedValue)
164 | m.showingPreview = false
165 | m.suggestedValue = ""
166 | m.runJqFilter()
167 | return m, nil
168 | }
169 | // Let the textarea handle the enter key for newlines - don't intercept it
170 | break
171 |
172 | case msg.String() == "up":
173 | m.viewport.LineUp(1)
174 | return m, nil
175 |
176 | case msg.String() == "down":
177 | m.viewport.LineDown(1)
178 | return m, nil
179 |
180 | case msg.String() == "pageup":
181 | m.viewport.ViewUp()
182 | return m, nil
183 | case msg.String() == "pagedown":
184 | m.viewport.ViewDown()
185 | return m, nil
186 |
187 | default:
188 | if m.showingPreview {
189 | m.showingPreview = false
190 | m.suggestedValue = ""
191 | return m, nil
192 | }
193 | }
194 | }
195 |
196 | // Handle character input and blinking
197 | cmd := m.updateInputs(msg)
198 |
199 | // Evaluate jq filter on input change
200 | m.runJqFilter()
201 |
202 | return m, cmd
203 | }
204 |
205 | func (m *model) updateInputs(msg tea.Msg) tea.Cmd {
206 | var cmd tea.Cmd
207 | m.textArea, cmd = m.textArea.Update(msg)
208 | return cmd
209 | }
210 |
211 | func (m model) isValidQuery() bool {
212 | query := strings.TrimSpace(m.textArea.Value())
213 | if query == "" {
214 | return false
215 | }
216 | _, err := gojq.Parse(query)
217 | return err == nil
218 | }
219 |
220 | func jsonStrToInterface(jsonStr string) (any, error) {
221 | var jsonData any
222 | err := json.Unmarshal([]byte(jsonStr), &jsonData)
223 | if err != nil {
224 | return nil, fmt.Errorf("Invalid JSON input: %s", err)
225 | }
226 | return jsonData, nil
227 | }
228 |
229 | func (m *model) runJqFilter() {
230 | query, err := gojq.Parse(m.textArea.Value())
231 | if err != nil {
232 | m.jqOutput = fmt.Sprintf("Invalid jq query: %s\n\nLast valid output:\n%s", err, m.lastOutput)
233 | m.updateViewportContent()
234 | return
235 | }
236 |
237 | var jsonData any
238 | err = json.Unmarshal([]byte(m.jsonInput), &jsonData)
239 | if err != nil {
240 | m.jqOutput = fmt.Sprintf("Invalid JSON input: %s\n\nLast valid output:\n%s", err, m.lastOutput)
241 | m.updateViewportContent()
242 | return
243 | }
244 |
245 | iter := query.Run(jsonData)
246 | var result []string
247 | isNull := true
248 | for {
249 | v, ok := iter.Next()
250 | if !ok {
251 | break
252 | }
253 | if err, ok := v.(error); ok {
254 | m.jqOutput = fmt.Sprintf("Error executing jq query: %s\n\nLast valid output:\n%s", err, m.lastOutput)
255 | m.updateViewportContent()
256 | return
257 | }
258 | output, err := json.MarshalIndent(v, "", " ")
259 | if err != nil {
260 | m.jqOutput = fmt.Sprintf("Error formatting output: %s\n\nLast valid output:\n%s", err, m.lastOutput)
261 | m.updateViewportContent()
262 | return
263 | }
264 | if string(output) != "null" {
265 | isNull = false
266 | result = append(result, string(output))
267 | }
268 | }
269 |
270 | if isNull {
271 | m.jqOutput = fmt.Sprintf("Query result is null\n\nLast valid output:\n%s", m.lastOutput)
272 | m.updateViewportContent()
273 | return
274 | }
275 |
276 | m.jqOutput = strings.Join(result, "\n")
277 | m.lastOutput = m.jqOutput
278 | m.updateViewportContent()
279 | }
280 |
281 | func (m *model) updateViewportContent() {
282 | prettyOutput, err := codec.PrettyFormat(m.jqOutput, codec.JSON, false, false)
283 | if err != nil {
284 | m.viewport.SetContent(fmt.Sprintf("Error formatting output: %s", err))
285 | return
286 | }
287 | m.viewport.SetContent(outputStyle.Render(prettyOutput))
288 | }
289 |
290 | func (m model) View() string {
291 | var b strings.Builder
292 |
293 | // Header with enhanced styling
294 | headerText := "qq Interactive Mode - jq Filter Editor"
295 | b.WriteString(headerStyle.Render(headerText))
296 | b.WriteString("\n")
297 | b.WriteString(borderStyle.Render(strings.Repeat("━", len(headerText)+4)))
298 | b.WriteString("\n\n")
299 |
300 | // Text area
301 | b.WriteString(m.textArea.View())
302 |
303 | // Preview suggestion with better styling
304 | if m.showingPreview && m.suggestedValue != "" {
305 | b.WriteString("\n")
306 | b.WriteString(previewStyle.Render("Suggestion: " + m.suggestedValue))
307 | }
308 |
309 | // Enhanced legend with better formatting
310 | b.WriteString("\n\n")
311 | legendText := "Tab: autocomplete | Enter: accept/newline | Ctrl+C/Esc: execute & exit | ↑↓: scroll"
312 | b.WriteString(legendStyle.Render(legendText))
313 | b.WriteString("\n")
314 | b.WriteString(borderStyle.Render(strings.Repeat("─", len(legendText))))
315 | b.WriteString("\n\n")
316 |
317 | // Output viewport
318 | b.WriteString(m.viewport.View())
319 |
320 | return b.String()
321 | }
322 |
323 | func printOutput(m model) {
324 | if m.gracefulExit {
325 | // Graceful exit with formatted output
326 | s := m.textArea.Value()
327 | fmt.Printf("\033[36m# Query: %s\033[0m\n", s)
328 | o, err := codec.PrettyFormat(m.jqOutput, codec.JSON, false, false)
329 | if err != nil {
330 | fmt.Printf("\033[31mError formatting output: %s\033[0m\n", err)
331 | os.Exit(1)
332 | }
333 | fmt.Println(o)
334 | os.Exit(0)
335 | } else {
336 | // Abrupt exit
337 | fmt.Println("\033[33mExited without executing query\033[0m")
338 | os.Exit(0)
339 | }
340 | }
341 |
342 | func Interact(s string) {
343 | m, err := tea.NewProgram(newModel(s), tea.WithAltScreen()).Run()
344 | if err != nil {
345 | fmt.Println("Error running program:", err)
346 | os.Exit(1)
347 | }
348 | printOutput(m.(model))
349 | }
350 |
--------------------------------------------------------------------------------
/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/JohnCGriffin/overflow v0.0.0-20211019200055-46fa312c352c h1:RGWPOewvKIROun94nF7v2cua9qP+thov/7M50KEoeSU=
4 | github.com/JohnCGriffin/overflow v0.0.0-20211019200055-46fa312c352c/go.mod h1:X0CRv0ky0k6m906ixxpzmDRLvX58TFUKS2eePweuyxk=
5 | github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
6 | github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
7 | github.com/agext/levenshtein v1.2.3 h1:YB2fHEn0UJagG8T1rrWknE3ZQzWM06O8AMAatNn7lmo=
8 | github.com/agext/levenshtein v1.2.3/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558=
9 | github.com/alecthomas/chroma v0.10.0 h1:7XDcGkCQopCNKjZHfYrNLraA+M7e0fMiJ/Mfikbfjek=
10 | github.com/alecthomas/chroma v0.10.0/go.mod h1:jtJATyUxlIORhUOFNA9NZDWGAQ8wpxQQqNSB4rjA/1s=
11 | github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
12 | github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
13 | github.com/apache/arrow/go/v16 v16.1.0 h1:dwgfOya6s03CzH9JrjCBx6bkVb4yPD4ma3haj9p7FXI=
14 | github.com/apache/arrow/go/v16 v16.1.0/go.mod h1:9wnc9mn6vEDTRIm4+27pEjQpRKuTvBaessPoEXQzxWA=
15 | github.com/apache/thrift v0.19.0 h1:sOqkWPzMj7w6XaYbJQG7m4sGqVolaW/0D28Ln7yPzMk=
16 | github.com/apache/thrift v0.19.0/go.mod h1:SUALL216IiaOw2Oy+5Vs9lboJ/t9g40C+G07Dc0QC1I=
17 | github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY=
18 | github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4=
19 | github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
20 | github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
21 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
22 | github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
23 | github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
24 | github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
25 | github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
26 | github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
27 | github.com/charmbracelet/bubbletea v1.3.5 h1:JAMNLTbqMOhSwoELIr0qyP4VidFq72/6E9j7HHmRKQc=
28 | github.com/charmbracelet/bubbletea v1.3.5/go.mod h1:TkCnmH+aBd4LrXhXcqrKiYwRs7qyQx5rBgH5fVY3v54=
29 | github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40=
30 | github.com/charmbracelet/colorprofile v0.3.1/go.mod h1:/GkGusxNs8VB/RSOh3fu0TJmQ4ICMMPApIIVn0KszZ0=
31 | github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
32 | github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
33 | github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0=
34 | github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
35 | github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k=
36 | github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
37 | github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
38 | github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
39 | github.com/clbanning/mxj/v2 v2.7.0 h1:WA/La7UGCanFe5NpHF0Q3DNtnCsVoxbPKuyBNHWRyME=
40 | github.com/clbanning/mxj/v2 v2.7.0/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s=
41 | github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
42 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
43 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
44 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
45 | github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
46 | github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
47 | github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
48 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
49 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
50 | github.com/go-test/deep v1.0.7 h1:/VSMRlnY/JSyqxQUzQLKVMAskpY/NZKFA5j2P+0pP2M=
51 | github.com/go-test/deep v1.0.7/go.mod h1:QV8Hv/iy04NyLBxAdO9njL0iVPN1S4d/A3NVv1V36o8=
52 | github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
53 | github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
54 | github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
55 | github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
56 | github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
57 | github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
58 | github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
59 | github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
60 | github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
61 | github.com/google/flatbuffers v24.3.25+incompatible h1:CX395cjN9Kke9mmalRoL3d81AtFUxJM+yDthflgJGkI=
62 | github.com/google/flatbuffers v24.3.25+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
63 | github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
64 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
65 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
66 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
67 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
68 | github.com/hashicorp/hcl/v2 v2.23.0 h1:Fphj1/gCylPxHutVSEOf2fBOh1VE4AuLV7+kbJf3qos=
69 | github.com/hashicorp/hcl/v2 v2.23.0/go.mod h1:62ZYHrXgPoX8xBnzl8QzbWq4dyDsDtfCRgIq1rbJEvA=
70 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
71 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
72 | github.com/itchyny/gojq v0.12.17 h1:8av8eGduDb5+rvEdaOO+zQUjA04MS0m3Ps8HiD+fceg=
73 | github.com/itchyny/gojq v0.12.17/go.mod h1:WBrEMkgAfAGO1LUcGOckBl5O726KPp+OlkKug0I/FEY=
74 | github.com/itchyny/timefmt-go v0.1.6 h1:ia3s54iciXDdzWzwaVKXZPbiXzxxnv1SPGFfM/myJ5Q=
75 | github.com/itchyny/timefmt-go v0.1.6/go.mod h1:RRDZYC5s9ErkjQvTvvU7keJjxUYzIISJGxm9/mAERQg=
76 | github.com/klauspost/asmfmt v1.3.2 h1:4Ri7ox3EwapiOjCki+hw14RyKk201CN4rzyCJRFLpK4=
77 | github.com/klauspost/asmfmt v1.3.2/go.mod h1:AG8TuvYojzulgDAMCnYn50l/5QV3Bs/tp6j0HLHbNSE=
78 | github.com/klauspost/compress v1.17.7 h1:ehO88t2UGzQK66LMdE8tibEd1ErmzZjNEqWkjLAKQQg=
79 | github.com/klauspost/compress v1.17.7/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
80 | github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
81 | github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
82 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
83 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
84 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
85 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
86 | github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
87 | github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
88 | github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
89 | github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
90 | github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8 h1:AMFGa4R4MiIpspGNG7Z948v4n35fFGB3RR3G/ry4FWs=
91 | github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8/go.mod h1:mC1jAcsrzbxHt8iiaC+zU4b1ylILSosueou12R++wfY=
92 | github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3 h1:+n/aFZefKZp7spd8DFdX7uMikMLXX4oubIzJF4kv/wI=
93 | github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3/go.mod h1:RagcQ7I8IeTMnF8JTXieKnO4Z6JCsikNEzj0DwauVzE=
94 | github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0=
95 | github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0=
96 | github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
97 | github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
98 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
99 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
100 | github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
101 | github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
102 | github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
103 | github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
104 | github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ=
105 | github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
106 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
107 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
108 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
109 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
110 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
111 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
112 | github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
113 | github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
114 | github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
115 | github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
116 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
117 | github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
118 | github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
119 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
120 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
121 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
122 | github.com/tmccombs/hcl2json v0.6.7 h1:RYKTs4kd/gzRsEiv7J3M2WQ7TYRYZVc+0H0pZdERkxA=
123 | github.com/tmccombs/hcl2json v0.6.7/go.mod h1:lJgBOOGDpbhjvdG2dLaWsqB4KBzul2HytfDTS3H465o=
124 | github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8=
125 | github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok=
126 | github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
127 | github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
128 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
129 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
130 | github.com/zclconf/go-cty v1.16.3 h1:osr++gw2T61A8KVYHoQiFbFd1Lh3JOCXc/jFLJXKTxk=
131 | github.com/zclconf/go-cty v1.16.3/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE=
132 | github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940 h1:4r45xpDWB6ZMSMNJFMOjqrGHynW3DIBuR2H9j0ug+Mo=
133 | github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940/go.mod h1:CmBdvvj3nqzfzJ6nTCIwDTPZ56aVGvDrmztiO5g3qrM=
134 | github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
135 | github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
136 | github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
137 | github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
138 | golang.org/x/exp v0.0.0-20240222234643-814bf88cf225 h1:LfspQV/FYTatPTr/3HzIcmiUFH7PGP+OQ6mgDYo3yuQ=
139 | golang.org/x/exp v0.0.0-20240222234643-814bf88cf225/go.mod h1:CxmFvTBINI24O/j8iY7H1xHzx2i4OsyguNBmN/uPtqc=
140 | golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
141 | golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
142 | golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
143 | golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
144 | golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
145 | golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
146 | golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
147 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
148 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
149 | golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
150 | golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
151 | golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
152 | golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
153 | golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
154 | golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
155 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
156 | golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU=
157 | golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
158 | gonum.org/v1/gonum v0.15.0 h1:2lYxjRbTYyxkJxlhC+LvJIx3SsANPdRybu1tGj9/OrQ=
159 | gonum.org/v1/gonum v0.15.0/go.mod h1:xzZVBJBtS+Mz4q0Yl2LJTk+OxOg4jiXZ7qBoM0uISGo=
160 | google.golang.org/genproto/googleapis/rpc v0.0.0-20240123012728-ef4313101c80 h1:AjyfHzEPEFp/NpvfN5g+KDla3EMojjhRVZc1i7cj+oM=
161 | google.golang.org/genproto/googleapis/rpc v0.0.0-20240123012728-ef4313101c80/go.mod h1:PAREbraiVEVGVdTZsVWjSbbTtSyGbAgIIvni8a8CD5s=
162 | google.golang.org/grpc v1.62.1 h1:B4n+nfKzOICUXMgyrNd19h/I9oH0L1pizfk1d4zSgTk=
163 | google.golang.org/grpc v1.62.1/go.mod h1:IWTG0VlJLCh1SkC58F7np9ka9mx/WNkjl4PGJaiq+QE=
164 | google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
165 | google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
166 | google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI=
167 | google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
168 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
169 | gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
170 | gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
171 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
172 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
173 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
174 |
--------------------------------------------------------------------------------