├── .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 |
43 |
44 | 45 | 46 |
47 |
48 | 49 | 50 |
51 |
52 | 53 | 54 |
55 |
56 | 57 | 58 |
59 |
60 | 61 | 62 | 63 | 64 | 65 |
66 |
67 | 68 | 74 |
75 |
76 | 77 | 78 |
79 |
80 | 81 | 82 |
83 | 84 |
85 | 86 | 87 | 88 | -------------------------------------------------------------------------------- /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 | [![Go](https://github.com/JFryy/qq/actions/workflows/go.yml/badge.svg)](https://github.com/JFryy/qq/actions/workflows/go.yml) 4 | [![Docker Build](https://github.com/JFryy/qq/actions/workflows/docker-image.yml/badge.svg)](https://github.com/JFryy/qq/actions/workflows/docker-image.yml) 5 | [![Go Version](https://img.shields.io/github/go-mod/go-version/JFryy/qq)](https://golang.org/) 6 | [![License](https://img.shields.io/github/license/JFryy/qq)](https://github.com/JFryy/qq/blob/main/LICENSE) 7 | [![Latest Release](https://img.shields.io/github/v/release/JFryy/qq)](https://github.com/JFryy/qq/releases) 8 | [![Docker Pulls](https://img.shields.io/docker/pulls/jfryy/qq)](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 | ![Demo GIF](docs/demo.gif) 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 | --------------------------------------------------------------------------------