├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── dependabot.yml └── workflows │ ├── build.yml │ └── release.yml ├── .gitignore ├── .goreleaser.yml ├── Dockerfile ├── LICENSE ├── README.md ├── assets └── images │ └── screenshot.png ├── cmd ├── root.go └── root_test.go ├── codecov.yml ├── docker-compose.yml ├── docs └── xq.man ├── go.mod ├── go.sum ├── internal └── utils │ ├── config.go │ ├── config_test.go │ ├── jsonutil.go │ ├── jsonutil_test.go │ ├── utils.go │ └── utils_test.go ├── main.go ├── scripts └── install.sh ├── test ├── data │ ├── config │ │ ├── config1 │ │ └── config2 │ ├── html │ │ ├── formatted.html │ │ ├── formatted.xml │ │ ├── formatted2.html │ │ ├── formatted3.html │ │ ├── formatted4.html │ │ ├── formatted5.html │ │ ├── formatted6.html │ │ ├── unformatted.html │ │ ├── unformatted.xml │ │ ├── unformatted2.html │ │ ├── unformatted3.html │ │ ├── unformatted4.html │ │ ├── unformatted5.html │ │ └── unformatted6.html │ ├── json │ │ ├── formatted.json │ │ ├── formatted2.json │ │ ├── formatted3.json │ │ ├── unformatted.json │ │ ├── unformatted2.json │ │ └── unformatted3.json │ ├── xml │ │ ├── formatted.xml │ │ ├── formatted10.xml │ │ ├── formatted11.xml │ │ ├── formatted12.xml │ │ ├── formatted13.xml │ │ ├── formatted14.xml │ │ ├── formatted15.xml │ │ ├── formatted16.xml │ │ ├── formatted2.xml │ │ ├── formatted3.xml │ │ ├── formatted4.xml │ │ ├── formatted5.xml │ │ ├── formatted6.xml │ │ ├── formatted7.xml │ │ ├── formatted8.xml │ │ ├── formatted9.xml │ │ ├── unformatted.xml │ │ ├── unformatted10.xml │ │ ├── unformatted11.xml │ │ ├── unformatted12.xml │ │ ├── unformatted13.xml │ │ ├── unformatted14.xml │ │ ├── unformatted15.xml │ │ ├── unformatted16.xml │ │ ├── unformatted2.xml │ │ ├── unformatted3.xml │ │ ├── unformatted4.xml │ │ ├── unformatted5.xml │ │ ├── unformatted6.xml │ │ ├── unformatted7.xml │ │ ├── unformatted8.xml │ │ └── unformatted9.xml │ └── xml2json │ │ ├── formatted.json │ │ ├── formatted2.json │ │ ├── formatted3.json │ │ ├── formatted4.json │ │ ├── unformatted.xml │ │ ├── unformatted2.xml │ │ ├── unformatted3.xml │ │ └── unformatted4.xml └── pipe-test.sh └── version /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Something works wrong 4 | title: '' 5 | labels: bug 6 | assignees: sibprogrammer 7 | 8 | --- 9 | 10 | **Problem Statement** 11 | 12 | TBD 13 | 14 | **Steps to Reproduce** 15 | 16 | TBD 17 | 18 | **Actual Result** 19 | 20 | TBD 21 | 22 | **Expected Result** 23 | 24 | TBD 25 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: sibprogrammer 7 | 8 | --- 9 | 10 | **Problem Statement** 11 | 12 | TBD 13 | 14 | **Expected Result** 15 | 16 | TBD 17 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "gomod" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | commit-message: 8 | prefix: "TECH " 9 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | push: 5 | paths: 6 | - '**.go' 7 | - 'go.mod' 8 | - 'build.yml' 9 | pull_request: 10 | paths: 11 | - '**.go' 12 | - 'go.mod' 13 | - 'build.yml' 14 | 15 | jobs: 16 | build: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/checkout@v4 20 | 21 | - name: Set up Go 22 | uses: actions/setup-go@v5 23 | with: 24 | go-version: ^1.23 25 | 26 | - name: Build 27 | run: go build 28 | 29 | - name: Test 30 | run: go test -coverprofile=coverage.txt -covermode=atomic -v ./... 31 | 32 | - name: Upload coverage to Codecov 33 | uses: codecov/codecov-action@v4 34 | with: 35 | token: ${{ secrets.CODECOV_TOKEN }} 36 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | jobs: 9 | release: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v4 14 | with: 15 | fetch-depth: 0 16 | 17 | - name: Set up Go 18 | uses: actions/setup-go@v5 19 | with: 20 | go-version: ^1.23 21 | 22 | - name: Release 23 | uses: goreleaser/goreleaser-action@v5 24 | with: 25 | distribution: goreleaser 26 | version: latest 27 | args: release --rm-dist 28 | env: 29 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /xq 2 | /.env 3 | /dist/ 4 | /coverage.txt 5 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | project_name: xq 2 | builds: 3 | - env: 4 | - CGO_ENABLED=0 5 | goos: 6 | - linux 7 | - darwin 8 | - aix 9 | - windows 10 | goarch: 11 | - 386 12 | - amd64 13 | - arm64 14 | - arm 15 | - ppc64 16 | goamd64: 17 | - '' 18 | goarm: 19 | - '6' 20 | - '7' 21 | ignore: 22 | - goos: linux 23 | goarch: ppc64 24 | - goos: windows 25 | goarch: 386 26 | - goos: windows 27 | goarch: arm 28 | - goos: windows 29 | goarch: arm64 30 | archives: 31 | - format_overrides: 32 | - goos: windows 33 | format: zip 34 | checksum: 35 | name_template: 'checksums.txt' 36 | snapshot: 37 | name_template: "{{ .Tag }}" 38 | changelog: 39 | filters: 40 | exclude: 41 | - '^TECH' 42 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.24 AS builder 2 | 3 | ARG CGO_ENABLED=0 4 | 5 | WORKDIR /opt 6 | 7 | COPY go.mod go.sum ./ 8 | RUN go mod download 9 | COPY . . 10 | RUN go build 11 | 12 | FROM ubuntu:22.04 13 | 14 | COPY --from=builder /opt/xq /usr/local/bin/xq 15 | 16 | ENTRYPOINT ["bash"] 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021-2024 Alexey Yuzhakov 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # xq 2 | 3 | [![build](https://github.com/sibprogrammer/xq/workflows/build/badge.svg)](https://github.com/sibprogrammer/xq/actions) 4 | [![Go Report Card](https://goreportcard.com/badge/github.com/sibprogrammer/xq)](https://goreportcard.com/report/github.com/sibprogrammer/xq) 5 | [![Codecov](https://codecov.io/gh/sibprogrammer/xq/branch/master/graph/badge.svg?token=G6QX77SQOH)](https://codecov.io/gh/sibprogrammer/xq) 6 | [![Scc Count](https://sloc.xyz/github/sibprogrammer/xq/)](https://github.com/sibprogrammer/xq/) 7 | [![Homebrew](https://img.shields.io/badge/dynamic/json.svg?url=https://formulae.brew.sh/api/formula/xq.json&query=$.versions.stable&label=homebrew)](https://formulae.brew.sh/formula/xq) 8 | [![Macports](https://repology.org/badge/version-for-repo/macports/xq-sibprogrammer.svg)](https://repology.org/project/xq-sibprogrammer/versions) 9 | 10 | Command-line XML and HTML beautifier and content extractor. 11 | 12 | ![xq](./assets/images/screenshot.png?raw=true) 13 | 14 | # Features 15 | 16 | * Syntax highlighting 17 | * Automatic indentation and formatting 18 | * Automatic pagination 19 | * Node content extraction 20 | 21 | # Usage 22 | 23 | Format an XML file and highlight the syntax: 24 | 25 | ``` 26 | xq test/data/xml/unformatted.xml 27 | ``` 28 | 29 | `xq` also accepts input through `stdin`: 30 | 31 | ``` 32 | curl -s https://www.w3schools.com/xml/note.xml | xq 33 | ``` 34 | 35 | HTML content can be formatted and highlighted as well (using `-m` flag): 36 | 37 | ``` 38 | xq -m test/data/html/formatted.html 39 | ``` 40 | 41 | It is possible to extract the content using XPath query language. 42 | `-x` parameter accepts XPath expression. 43 | 44 | Extract the text content of all nodes with `city` name: 45 | 46 | ``` 47 | cat test/data/xml/unformatted.xml | xq -x //city 48 | ``` 49 | 50 | Extract the value of attribute named `status` and belonging to `user`: 51 | 52 | ``` 53 | cat test/data/xml/unformatted.xml | xq -x /user/@status 54 | ``` 55 | 56 | See https://en.wikipedia.org/wiki/XPath for details. 57 | 58 | It is possible to use CSS selector to extract the content as well: 59 | 60 | ``` 61 | cat test/data/html/unformatted.html | xq -q "body > p" 62 | ``` 63 | 64 | Extract an attribute value instead of node content additional option `--attr` (`-a`) can be used: 65 | 66 | ``` 67 | cat test/data/html/unformatted.html | xq -q "head > script" -a "src" 68 | ``` 69 | 70 | Extract part of HTML with tags (not only text content) using CSS selector: 71 | 72 | ``` 73 | cat test/data/html/unformatted.html | xq -n -q "head" 74 | ``` 75 | 76 | Output the result as JSON: 77 | 78 | ``` 79 | cat test/data/xml/unformatted.xml | xq -j 80 | ``` 81 | 82 | This will output the result in JSON format, preserving the XML structure. The JSON output will be an object where: 83 | - XML elements become object keys 84 | - Attributes are prefixed with "@" 85 | - Text content is stored under "#text" if the element has attributes or child elements 86 | - Repeated elements are automatically converted to arrays 87 | - Elements with only text content are represented as strings 88 | 89 | # Installation 90 | 91 | The preferable ways to install the utility are described below. 92 | 93 | For macOS, via [Homebrew](https://brew.sh): 94 | ``` 95 | brew install xq 96 | ``` 97 | 98 | For macOS, via [MacPorts](https://www.macports.org): 99 | ``` 100 | sudo port install xq 101 | ``` 102 | 103 | For Linux using custom installer: 104 | ``` 105 | curl -sSL https://bit.ly/install-xq | sudo bash 106 | ``` 107 | 108 | For Ubuntu 22.10 or higher via package manager: 109 | ``` 110 | apt-get install xq 111 | ``` 112 | 113 | For Fedora via package manager: 114 | ``` 115 | dnf install xq 116 | ``` 117 | 118 | A more detailed list of Linux distros that package the `xq` utility can be found here: 119 | https://repology.org/project/xq-sibprogrammer/versions 120 | 121 | If you have Go toolchain installed, you can use the following command to install `xq`: 122 | ``` 123 | go install github.com/sibprogrammer/xq@latest 124 | ``` 125 | 126 | You can play with the `xq` utility using the Dockerized environment: 127 | 128 | ``` 129 | docker compose run --rm xq 130 | xq /opt/examples/xml/unformatted.xml 131 | ``` 132 | -------------------------------------------------------------------------------- /assets/images/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sibprogrammer/xq/50820fb1825e7f1a4e317dec462e58751d1688a3/assets/images/screenshot.png -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "os" 10 | "path" 11 | "strings" 12 | 13 | "github.com/antchfx/xmlquery" 14 | "github.com/sibprogrammer/xq/internal/utils" 15 | "github.com/spf13/cobra" 16 | "github.com/spf13/pflag" 17 | ) 18 | 19 | // Version information 20 | var Version string 21 | 22 | var rootCmd = NewRootCmd() 23 | 24 | func NewRootCmd() *cobra.Command { 25 | return &cobra.Command{ 26 | Use: "xq", 27 | Short: "Command-line XML and HTML beautifier and content extractor", 28 | SilenceUsage: true, 29 | RunE: func(cmd *cobra.Command, args []string) error { 30 | var err error 31 | var reader io.Reader 32 | var indent string 33 | 34 | if indent, err = getIndent(cmd.Flags()); err != nil { 35 | return err 36 | } 37 | if len(args) == 0 { 38 | fileInfo, _ := os.Stdin.Stat() 39 | 40 | if (fileInfo.Mode() & os.ModeCharDevice) != 0 { 41 | _ = cmd.Help() 42 | return nil 43 | } 44 | 45 | reader = os.Stdin 46 | } else { 47 | var err error 48 | if reader, err = os.Open(args[len(args)-1]); err != nil { 49 | return err 50 | } 51 | } 52 | 53 | xPathQuery, singleNode := getXpathQuery(cmd.Flags()) 54 | withTags, _ := cmd.Flags().GetBool("node") 55 | colors := getColorMode(cmd.Flags()) 56 | 57 | options := utils.QueryOptions{ 58 | WithTags: withTags, 59 | Indent: indent, 60 | Colors: colors, 61 | } 62 | 63 | cssQuery, _ := cmd.Flags().GetString("query") 64 | cssAttr, _ := cmd.Flags().GetString("attr") 65 | if cssAttr != "" && cssQuery == "" { 66 | return errors.New("query option (-q) is missed for attribute selection") 67 | } 68 | jsonOutputMode, _ := cmd.Flags().GetBool("json") 69 | 70 | pr, pw := io.Pipe() 71 | errChan := make(chan error, 1) 72 | 73 | go func() { 74 | defer close(errChan) 75 | defer pw.Close() 76 | 77 | var err error 78 | if xPathQuery != "" { 79 | err = utils.XPathQuery(reader, pw, xPathQuery, singleNode, options) 80 | } else if cssQuery != "" { 81 | err = utils.CSSQuery(reader, pw, cssQuery, cssAttr, options) 82 | } else { 83 | var contentType utils.ContentType 84 | contentType, reader = detectFormat(cmd.Flags(), reader) 85 | if jsonOutputMode { 86 | err = processAsJSON(cmd.Flags(), reader, pw, contentType) 87 | } else { 88 | switch contentType { 89 | case utils.ContentHtml: 90 | err = utils.FormatHtml(reader, pw, indent, colors) 91 | case utils.ContentXml: 92 | err = utils.FormatXml(reader, pw, indent, colors) 93 | case utils.ContentJson: 94 | err = utils.FormatJson(reader, pw, indent, colors) 95 | default: 96 | err = fmt.Errorf("unknown content type: %v", contentType) 97 | } 98 | } 99 | } 100 | 101 | errChan <- err 102 | }() 103 | 104 | if err := utils.PagerPrint(pr, cmd.OutOrStdout()); err != nil { 105 | return err 106 | } 107 | 108 | return <-errChan 109 | }, 110 | } 111 | } 112 | 113 | func InitFlags(cmd *cobra.Command) { 114 | homeDir, _ := os.UserHomeDir() 115 | configFile := path.Join(homeDir, ".xq") 116 | if err := utils.LoadConfig(configFile); err != nil { 117 | fmt.Printf("Error while reading the config file: %v\n", err) 118 | os.Exit(1) 119 | } 120 | 121 | cmd.Version = Version 122 | 123 | cmd.Flags().BoolP("help", "h", false, "Print this help message") 124 | cmd.Flags().BoolP("version", "v", false, "Print version information") 125 | cmd.PersistentFlags().StringP("xpath", "x", "", "Extract the node(s) from XML") 126 | cmd.PersistentFlags().StringP("extract", "e", "", "Extract a single node from XML") 127 | cmd.PersistentFlags().Bool("tab", utils.GetConfig().Tab, "Use tabs for indentation") 128 | cmd.PersistentFlags().Int("indent", utils.GetConfig().Indent, 129 | "Use the given number of spaces for indentation") 130 | cmd.PersistentFlags().Bool("no-color", utils.GetConfig().NoColor, "Disable colorful output") 131 | cmd.PersistentFlags().BoolP("color", "c", utils.GetConfig().Color, 132 | "Force colorful output") 133 | cmd.PersistentFlags().BoolP("html", "m", utils.GetConfig().Html, "Use HTML formatter") 134 | cmd.PersistentFlags().StringP("query", "q", "", 135 | "Extract the node(s) using CSS selector") 136 | cmd.PersistentFlags().StringP("attr", "a", "", 137 | "Extract an attribute value instead of node content for provided CSS query") 138 | cmd.PersistentFlags().BoolP("node", "n", utils.GetConfig().Node, 139 | "Return the node content instead of text") 140 | cmd.PersistentFlags().BoolP("json", "j", false, "Output the result as JSON") 141 | cmd.PersistentFlags().Bool("compact", false, "Compact JSON output (no indentation)") 142 | cmd.PersistentFlags().IntP("depth", "d", -1, "Maximum nesting depth for JSON output (-1 for unlimited)") 143 | } 144 | 145 | func Execute() { 146 | InitFlags(rootCmd) 147 | 148 | if err := rootCmd.Execute(); err != nil { 149 | os.Exit(1) 150 | } 151 | } 152 | 153 | func getIndent(flags *pflag.FlagSet) (string, error) { 154 | var indentWidth int 155 | var tabIndent bool 156 | var err error 157 | 158 | if indentWidth, err = flags.GetInt("indent"); err != nil { 159 | return "", err 160 | } 161 | if indentWidth < 0 || indentWidth > 8 { 162 | return "", errors.New("indent should be between 0-8 spaces") 163 | } 164 | 165 | indent := strings.Repeat(" ", indentWidth) 166 | 167 | if tabIndent, err = flags.GetBool("tab"); err != nil { 168 | return "", err 169 | } 170 | 171 | if tabIndent { 172 | indent = "\t" 173 | } 174 | 175 | return indent, nil 176 | } 177 | 178 | func getXpathQuery(flags *pflag.FlagSet) (query string, single bool) { 179 | if query, _ = flags.GetString("xpath"); query != "" { 180 | return query, false 181 | } 182 | 183 | query, _ = flags.GetString("extract") 184 | return query, true 185 | } 186 | 187 | func getColorMode(flags *pflag.FlagSet) int { 188 | colors := utils.ColorsDefault 189 | 190 | disableColors, _ := flags.GetBool("no-color") 191 | if disableColors { 192 | colors = utils.ColorsDisabled 193 | } 194 | 195 | forcedColors, _ := flags.GetBool("color") 196 | if forcedColors { 197 | colors = utils.ColorsForced 198 | } 199 | 200 | return colors 201 | } 202 | 203 | func detectFormat(flags *pflag.FlagSet, origReader io.Reader) (utils.ContentType, io.Reader) { 204 | isHtmlFormatter, _ := flags.GetBool("html") 205 | if isHtmlFormatter { 206 | return utils.ContentHtml, origReader 207 | } 208 | 209 | buf := make([]byte, 10) 210 | length, err := origReader.Read(buf) 211 | if err != nil { 212 | return utils.ContentText, origReader 213 | } 214 | 215 | reader := io.MultiReader(bytes.NewReader(buf[:length]), origReader) 216 | 217 | if utils.IsJSON(string(buf)) { 218 | return utils.ContentJson, reader 219 | } 220 | 221 | if utils.IsHTML(string(buf)) { 222 | return utils.ContentHtml, reader 223 | } 224 | 225 | return utils.ContentXml, reader 226 | } 227 | 228 | func processAsJSON(flags *pflag.FlagSet, reader io.Reader, w io.Writer, contentType utils.ContentType) error { 229 | var ( 230 | jsonCompact bool 231 | jsonDepth int 232 | result interface{} 233 | ) 234 | jsonCompact, _ = flags.GetBool("compact") 235 | if flags.Changed("depth") { 236 | jsonDepth, _ = flags.GetInt("depth") 237 | } else { 238 | jsonDepth = -1 239 | } 240 | 241 | switch contentType { 242 | case utils.ContentXml, utils.ContentHtml: 243 | doc, err := xmlquery.Parse(reader) 244 | if err != nil { 245 | return fmt.Errorf("error while parsing XML: %w", err) 246 | } 247 | result = utils.NodeToJSON(doc, jsonDepth) 248 | case utils.ContentJson: 249 | decoder := json.NewDecoder(reader) 250 | if err := decoder.Decode(&result); err != nil { 251 | return fmt.Errorf("error while parsing JSON: %w", err) 252 | } 253 | default: 254 | // Treat as plain text 255 | content, err := io.ReadAll(reader) 256 | if err != nil { 257 | return fmt.Errorf("error while reading content: %w", err) 258 | } 259 | result = map[string]interface{}{ 260 | "text": string(content), 261 | } 262 | } 263 | jsonData, err := json.Marshal(result) 264 | if err != nil { 265 | return fmt.Errorf("error while marshaling JSON: %w", err) 266 | } 267 | indent := "" 268 | if !jsonCompact { 269 | indent = " " 270 | } 271 | colors := getColorMode(flags) 272 | return utils.FormatJson(bytes.NewReader(jsonData), w, indent, colors) 273 | } 274 | -------------------------------------------------------------------------------- /cmd/root_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "path" 8 | "strings" 9 | "testing" 10 | 11 | "github.com/sibprogrammer/xq/internal/utils" 12 | "github.com/spf13/cobra" 13 | "github.com/spf13/pflag" 14 | "github.com/stretchr/testify/assert" 15 | ) 16 | 17 | func execute(cmd *cobra.Command, args ...string) (string, error) { 18 | buf := new(bytes.Buffer) 19 | cmd.SetOut(buf) 20 | cmd.SetErr(buf) 21 | if len(args) > 0 { 22 | cmd.SetArgs(args) 23 | } else { 24 | cmd.SetArgs([]string{}) 25 | } 26 | 27 | err := cmd.Execute() 28 | 29 | return strings.TrimSpace(buf.String()), err 30 | } 31 | 32 | func TestRootCmd(t *testing.T) { 33 | command := NewRootCmd() 34 | InitFlags(command) 35 | 36 | var output string 37 | var err error 38 | xmlFilePath := path.Join("..", "test", "data", "xml", "unformatted.xml") 39 | htmlFilePath := path.Join("..", "test", "data", "html", "unformatted.html") 40 | jsonFilePath := path.Join("..", "test", "data", "json", "unformatted.json") 41 | 42 | output, err = execute(command) 43 | assert.Nil(t, err) 44 | assert.Contains(t, output, "Usage:") 45 | 46 | output, err = execute(command, xmlFilePath) 47 | assert.Nil(t, err) 48 | assert.Contains(t, output, "This is not a real user") 49 | 50 | output, err = execute(command, "--no-color", xmlFilePath) 51 | assert.Nil(t, err) 52 | assert.Contains(t, output, "first_name") 53 | 54 | output, err = execute(command, "--indent", "0", xmlFilePath) 55 | assert.Nil(t, err) 56 | assert.NotContains(t, output, "\n") 57 | 58 | output, err = execute(command, jsonFilePath) 59 | assert.Nil(t, err) 60 | assert.Contains(t, output, "{") 61 | 62 | output, err = execute(command, "--tab", xmlFilePath) 63 | assert.Nil(t, err) 64 | assert.Contains(t, output, "\t") 65 | 66 | output, err = execute(command, "-m", htmlFilePath) 67 | assert.Nil(t, err) 68 | assert.Contains(t, output, "") 69 | 70 | output, err = execute(command, "-q", "body > p", htmlFilePath) 71 | assert.Nil(t, err) 72 | assert.Contains(t, output, "text") 73 | 74 | output, err = execute(command, "-x", "/user/@status", xmlFilePath) 75 | assert.Nil(t, err) 76 | assert.Contains(t, output, "active") 77 | 78 | output, err = execute(command, "--no-color", "-x", "/user/@status", xmlFilePath) 79 | assert.Nil(t, err) 80 | assert.Contains(t, output, "active") 81 | 82 | output, err = execute(command, "--color", "-x", "/user/@status", xmlFilePath) 83 | assert.Nil(t, err) 84 | assert.Contains(t, output, "active") 85 | 86 | _, err = execute(command, "nonexistent.xml") 87 | assert.ErrorContains(t, err, "no such file or directory") 88 | 89 | _, err = execute(command, "--indent", "-1", xmlFilePath) 90 | assert.ErrorContains(t, err, "indent should be") 91 | 92 | _, err = execute(command, "--indent", "incorrect", xmlFilePath) 93 | assert.ErrorContains(t, err, "invalid argument") 94 | } 95 | 96 | func TestProcessAsJSON(t *testing.T) { 97 | tests := []struct { 98 | name string 99 | input string 100 | contentType utils.ContentType 101 | flags map[string]interface{} 102 | expected map[string]interface{} 103 | wantErr bool 104 | }{ 105 | { 106 | name: "Simple XML", 107 | input: "value", 108 | contentType: utils.ContentXml, 109 | expected: map[string]interface{}{ 110 | "root": map[string]interface{}{ 111 | "child": "value", 112 | }, 113 | }, 114 | }, 115 | {name: "Simple JSON", 116 | input: `{"root": {"child": "value"}}`, 117 | contentType: utils.ContentJson, 118 | expected: map[string]interface{}{ 119 | "root": map[string]interface{}{ 120 | "child": "value", 121 | }, 122 | }, 123 | }, 124 | { 125 | name: "Simple HTML", 126 | input: "

text

", 127 | contentType: utils.ContentHtml, 128 | expected: map[string]interface{}{ 129 | "html": map[string]interface{}{ 130 | "body": map[string]interface{}{ 131 | "p": "text", 132 | }, 133 | }, 134 | }, 135 | }, 136 | { 137 | name: "Plain text", 138 | input: "text", 139 | contentType: utils.ContentText, 140 | expected: map[string]interface{}{ 141 | "text": "text", 142 | }, 143 | }, 144 | { 145 | name: "invalid input", 146 | input: "thinking>\nI'll analyze each command and its output:\n", 147 | wantErr: true, 148 | }, 149 | { 150 | name: "combined", 151 | expected: map[string]interface{}{ 152 | "#text": "Thank you\nBye.", 153 | "thinking": "1. woop", 154 | }, 155 | input: `Thank you 156 | 157 | 1. woop 158 | 159 | 160 | Bye.`, 161 | }, 162 | } 163 | 164 | for _, tt := range tests { 165 | t.Run(tt.name, func(t *testing.T) { 166 | // Set up flags 167 | flags := pflag.NewFlagSet("test", pflag.ContinueOnError) 168 | flags.Bool("compact", false, "") 169 | flags.Int("depth", -1, "") 170 | for name, v := range tt.flags { 171 | _ = flags.Set(name, fmt.Sprint(v)) 172 | } 173 | 174 | reader := strings.NewReader(tt.input) 175 | var output bytes.Buffer 176 | 177 | err := processAsJSON(flags, reader, &output, tt.contentType) 178 | 179 | if tt.wantErr { 180 | assert.Error(t, err) 181 | } else { 182 | assert.NoError(t, err) 183 | 184 | var resultMap map[string]interface{} 185 | err = json.Unmarshal(output.Bytes(), &resultMap) 186 | assert.NoError(t, err) 187 | 188 | assert.Equal(t, tt.expected, resultMap) 189 | } 190 | }) 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | status: 3 | project: 4 | default: 5 | target: 80% 6 | threshold: 10% 7 | patch: 8 | default: 9 | informational: true 10 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | xq: 3 | build: . 4 | volumes: 5 | - ./test/data:/opt/examples 6 | stdin_open: true 7 | tty: true 8 | -------------------------------------------------------------------------------- /docs/xq.man: -------------------------------------------------------------------------------- 1 | .\" Manpage for xq utility 2 | .TH XQ 1 "06 Nov 2022" "" "xq man page" 3 | .SH NAME 4 | xq - command-line XML and HTML beautifier and content extractor 5 | .SH SYNOPSIS 6 | xq [\fIoptions...\fR] [\fIfile\fR] 7 | .SH DESCRIPTION 8 | Formats the provided \fIfile\fR and outputs it in the colorful mode. 9 | The file can be provided as an argument or via stdin. 10 | .SH OPTIONS 11 | .PP 12 | \fB--version\fR | \fB-v\fR 13 | .RS 4 14 | Prints versions information and exits. 15 | .RE 16 | .PP 17 | \fB--help\fR | \fB-h\fR 18 | .RS 4 19 | Prints the synopsis and a list of options and exits. 20 | .RE 21 | .PP 22 | \fB--indent\fR \fIint\fR 23 | .RS 4 24 | Uses the given number of spaces for indentation (default 2). 25 | .RE 26 | .PP 27 | \fB--color\fR | \fB-c\fR 28 | .RS 4 29 | Forces colorful output. 30 | .RE 31 | .PP 32 | \fB--no-color\fR 33 | .RS 4 34 | Disables colorful output (only formatting). 35 | .RE 36 | .PP 37 | \fB--tab\fR 38 | .RS 4 39 | Uses tabs instead of spaces for indentation. 40 | .RE 41 | .PP 42 | \fB--xpath\fR | \fB-x\fR \fIstring\fR 43 | .RS 4 44 | Extracts the node(s) from XML using provided XPath query. 45 | .RE 46 | .PP 47 | \fB--extract\fR | \fB-e\fR \fIstring\fR 48 | .RS 4 49 | Extracts a single node from XML using provided XPath query. 50 | .RE 51 | .PP 52 | \fB--query\fR | \fB-q\fR \fIstring\fR 53 | .RS 4 54 | Extracts the node(s) using CSS selector. 55 | .RE 56 | .PP 57 | \fB--attr\fR | \fB-a\fR \fIstring\fR 58 | .RS 4 59 | Extracts an attribute value instead of node content for provided CSS query. 60 | .RE 61 | .PP 62 | \fB--html\fR | \fB-m\fR 63 | .RS 4 64 | Uses HTML formatter instead of XML. 65 | .RE 66 | .PP 67 | \fB--node\fR | \fB-n\fR 68 | .RS 4 69 | Returns the node content instead of text. 70 | .RE 71 | .SH EXAMPLES 72 | .PP 73 | Format an XML file and highlight the syntax: 74 | 75 | .RS 4 76 | $ xq test/data/xml/unformatted.xml 77 | .RE 78 | .PP 79 | Utility also accepts input through stdin: 80 | 81 | .RS 4 82 | $ curl -s https://www.w3schools.com/xml/note.xml | xq 83 | .RE 84 | .PP 85 | HTML content can be formatted and highlighted using -m flag: 86 | 87 | .RS 4 88 | $ xq -m test/data/html/formatted.html 89 | .RE 90 | .PP 91 | Extract the text content of all nodes with city name: 92 | 93 | .RS 4 94 | $ cat test/data/xml/unformatted.xml | xq -x //city 95 | .RE 96 | .PP 97 | Extract the XML content of all nodes with city name: 98 | 99 | .RS 4 100 | $ cat test/data/xml/unformatted.xml | xq -n -x //city 101 | .RE 102 | .SH SEE ALSO 103 | .PP 104 | \fBhttps://github.com/sibprogrammer/xq\fR - official website 105 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/sibprogrammer/xq 2 | 3 | go 1.24.0 4 | 5 | require ( 6 | github.com/PuerkitoBio/goquery v1.10.3 7 | github.com/antchfx/xmlquery v1.4.4 8 | github.com/antchfx/xpath v1.3.4 9 | github.com/fatih/color v1.18.0 10 | github.com/spf13/cobra v1.9.1 11 | github.com/spf13/pflag v1.0.6 12 | github.com/stretchr/testify v1.10.0 13 | golang.org/x/net v0.40.0 14 | golang.org/x/text v0.25.0 15 | ) 16 | 17 | require ( 18 | github.com/andybalholm/cascadia v1.3.3 // indirect 19 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 20 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect 21 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 22 | github.com/kr/pretty v0.3.1 // indirect 23 | github.com/mattn/go-colorable v0.1.13 // indirect 24 | github.com/mattn/go-isatty v0.0.20 // indirect 25 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 26 | golang.org/x/sys v0.33.0 // indirect 27 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect 28 | gopkg.in/yaml.v3 v3.0.1 // indirect 29 | ) 30 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo= 2 | github.com/PuerkitoBio/goquery v1.10.3/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y= 3 | github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= 4 | github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= 5 | github.com/antchfx/xmlquery v1.4.4 h1:mxMEkdYP3pjKSftxss4nUHfjBhnMk4imGoR96FRY2dg= 6 | github.com/antchfx/xmlquery v1.4.4/go.mod h1:AEPEEPYE9GnA2mj5Ur2L5Q5/2PycJ0N9Fusrx9b12fc= 7 | github.com/antchfx/xpath v1.3.3/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs= 8 | github.com/antchfx/xpath v1.3.4 h1:1ixrW1VnXd4HurCj7qnqnR0jo14g8JMe20Fshg1Vgz4= 9 | github.com/antchfx/xpath v1.3.4/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs= 10 | github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 11 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 12 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 13 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 14 | github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= 15 | github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= 16 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= 17 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= 18 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 19 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 20 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 21 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 22 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 23 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 24 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 25 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 26 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 27 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 28 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 29 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 30 | github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= 31 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= 32 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 33 | github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= 34 | github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= 35 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 36 | github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= 37 | github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= 38 | github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= 39 | github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 40 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 41 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 42 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 43 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 44 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 45 | golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= 46 | golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= 47 | golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= 48 | golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= 49 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 50 | golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 51 | golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 52 | golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= 53 | golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= 54 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 55 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 56 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 57 | golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 58 | golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= 59 | golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= 60 | golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= 61 | golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= 62 | golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= 63 | golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= 64 | golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= 65 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 66 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 67 | golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 68 | golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= 69 | golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 70 | golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 71 | golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 72 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 73 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 74 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 75 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 76 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 77 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 78 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 79 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 80 | golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 81 | golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 82 | golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 83 | golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 84 | golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 85 | golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= 86 | golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 87 | golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= 88 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 89 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 90 | golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 91 | golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= 92 | golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= 93 | golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= 94 | golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= 95 | golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= 96 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 97 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 98 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 99 | golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 100 | golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 101 | golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= 102 | golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 103 | golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 104 | golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= 105 | golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= 106 | golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= 107 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 108 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 109 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 110 | golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= 111 | golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= 112 | golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= 113 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 114 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 115 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= 116 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 117 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 118 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 119 | -------------------------------------------------------------------------------- /internal/utils/config.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "bufio" 5 | "os" 6 | "strconv" 7 | "strings" 8 | ) 9 | 10 | type ConfigOptions struct { 11 | Indent int 12 | Tab bool 13 | NoColor bool 14 | Color bool 15 | Html bool 16 | Node bool 17 | } 18 | 19 | var config ConfigOptions 20 | 21 | func LoadConfig(fileName string) error { 22 | config.Indent = 2 23 | config.Tab = false 24 | config.NoColor = false 25 | config.Color = false 26 | config.Html = false 27 | config.Node = false 28 | 29 | file, err := os.Open(fileName) 30 | if os.IsNotExist(err) { 31 | return nil 32 | } else if err != nil { 33 | return err 34 | } 35 | 36 | defer func() { 37 | _ = file.Close() 38 | }() 39 | 40 | scanner := bufio.NewScanner(file) 41 | for scanner.Scan() { 42 | var text = scanner.Text() 43 | text = strings.TrimSpace(text) 44 | if strings.HasPrefix(text, "#") || len(text) == 0 { 45 | continue 46 | } 47 | var parts = strings.Split(text, "=") 48 | if len(parts) != 2 { 49 | continue 50 | } 51 | option, value := parts[0], parts[1] 52 | option = strings.TrimSpace(option) 53 | value = strings.TrimSpace(value) 54 | 55 | switch option { 56 | case "indent": 57 | config.Indent, _ = strconv.Atoi(value) 58 | case "tab": 59 | config.Tab, _ = strconv.ParseBool(value) 60 | case "no-color": 61 | config.NoColor, _ = strconv.ParseBool(value) 62 | case "color": 63 | config.Color, _ = strconv.ParseBool(value) 64 | } 65 | } 66 | 67 | return nil 68 | } 69 | 70 | func GetConfig() ConfigOptions { 71 | return config 72 | } 73 | -------------------------------------------------------------------------------- /internal/utils/config_test.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "github.com/stretchr/testify/assert" 5 | "path" 6 | "testing" 7 | ) 8 | 9 | func TestLoadConfig(t *testing.T) { 10 | var err error 11 | var config ConfigOptions 12 | 13 | err = LoadConfig(path.Join("..", "..", "test", "data", "config", "config1")) 14 | assert.Nil(t, err) 15 | config = GetConfig() 16 | assert.Equal(t, config.Indent, 8) 17 | assert.Equal(t, config.NoColor, true) 18 | 19 | err = LoadConfig(path.Join("..", "..", "test", "data", "config", "config2")) 20 | assert.Nil(t, err) 21 | config = GetConfig() 22 | assert.Equal(t, config.Indent, 2) 23 | } 24 | -------------------------------------------------------------------------------- /internal/utils/jsonutil.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/antchfx/xmlquery" 7 | ) 8 | 9 | // NodeToJSON converts an xmlquery.Node to a JSON object. The depth parameter 10 | // specifies how many levels of children to include in the result. A depth of 0 means 11 | // only the text content of the node is included. A depth of 1 means the node's children 12 | // are included, but not their children, and so on. 13 | func NodeToJSON(node *xmlquery.Node, depth int) interface{} { 14 | if node == nil { 15 | return nil 16 | } 17 | 18 | switch node.Type { 19 | case xmlquery.DocumentNode: 20 | result := make(map[string]interface{}) 21 | var textParts []string 22 | 23 | // Process the next sibling of the document node first (if any) 24 | if node.NextSibling != nil && node.NextSibling.Type == xmlquery.TextNode { 25 | text := strings.TrimSpace(node.NextSibling.Data) 26 | if text != "" { 27 | textParts = append(textParts, text) 28 | } 29 | } 30 | 31 | // Process all children, including siblings of the first child 32 | for child := node.FirstChild; child != nil; child = child.NextSibling { 33 | switch child.Type { 34 | case xmlquery.ElementNode: 35 | childResult := nodeToJSONInternal(child, depth) 36 | result[child.Data] = childResult 37 | case xmlquery.TextNode: 38 | text := strings.TrimSpace(child.Data) 39 | if text != "" { 40 | textParts = append(textParts, text) 41 | } 42 | } 43 | } 44 | 45 | if len(textParts) > 0 { 46 | result["#text"] = strings.Join(textParts, "\n") 47 | } 48 | return result 49 | 50 | case xmlquery.ElementNode: 51 | return nodeToJSONInternal(node, depth) 52 | 53 | case xmlquery.TextNode: 54 | return strings.TrimSpace(node.Data) 55 | 56 | default: 57 | return nil 58 | } 59 | } 60 | 61 | func nodeToJSONInternal(node *xmlquery.Node, depth int) interface{} { 62 | if depth == 0 { 63 | return getTextContent(node) 64 | } 65 | 66 | result := make(map[string]interface{}) 67 | for _, attr := range node.Attr { 68 | result["@"+attr.Name.Local] = attr.Value 69 | } 70 | 71 | var textParts []string 72 | for child := node.FirstChild; child != nil; child = child.NextSibling { 73 | switch child.Type { 74 | case xmlquery.TextNode: 75 | text := strings.TrimSpace(child.Data) 76 | if text != "" { 77 | textParts = append(textParts, text) 78 | } 79 | case xmlquery.ElementNode: 80 | childResult := nodeToJSONInternal(child, depth-1) 81 | addToResult(result, child.Data, childResult) 82 | } 83 | } 84 | 85 | if len(textParts) > 0 { 86 | if len(result) == 0 { 87 | return strings.Join(textParts, "\n") 88 | } 89 | result["#text"] = strings.Join(textParts, "\n") 90 | } 91 | 92 | return result 93 | } 94 | 95 | func getTextContent(node *xmlquery.Node) string { 96 | var parts []string 97 | for child := node.FirstChild; child != nil; child = child.NextSibling { 98 | switch child.Type { 99 | case xmlquery.TextNode: 100 | text := strings.TrimSpace(child.Data) 101 | if text != "" { 102 | parts = append(parts, text) 103 | } 104 | case xmlquery.ElementNode: 105 | parts = append(parts, getTextContent(child)) 106 | } 107 | } 108 | return strings.Join(parts, "\n") 109 | } 110 | 111 | func addToResult(result map[string]interface{}, key string, value interface{}) { 112 | if key == "" { 113 | return 114 | } 115 | if existing, ok := result[key]; ok { 116 | switch existing := existing.(type) { 117 | case []interface{}: 118 | result[key] = append(existing, value) 119 | default: 120 | result[key] = []interface{}{existing, value} 121 | } 122 | } else { 123 | result[key] = value 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /internal/utils/jsonutil_test.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "os" 7 | "path" 8 | "strings" 9 | "testing" 10 | 11 | "github.com/antchfx/xmlquery" 12 | "github.com/stretchr/testify/assert" 13 | ) 14 | 15 | func TestXmlToJSON(t *testing.T) { 16 | tests := []struct { 17 | unformattedFile string 18 | expectedFile string 19 | depth int 20 | }{ 21 | {"unformatted.xml", "formatted.json", -1}, 22 | {"unformatted2.xml", "formatted2.json", -1}, 23 | {"unformatted3.xml", "formatted3.json", -1}, 24 | {"unformatted4.xml", "formatted4.json", 1}, 25 | } 26 | 27 | for _, testCase := range tests { 28 | inputFileName := path.Join("..", "..", "test", "data", "xml2json", testCase.unformattedFile) 29 | unformattedXmlReader := getFileReader(inputFileName) 30 | 31 | outputFileName := path.Join("..", "..", "test", "data", "xml2json", testCase.expectedFile) 32 | data, jsonReadErr := os.ReadFile(outputFileName) 33 | assert.Nil(t, jsonReadErr) 34 | expectedJson := string(data) 35 | 36 | node, parseErr := xmlquery.Parse(unformattedXmlReader) 37 | assert.Nil(t, parseErr) 38 | result := NodeToJSON(node, testCase.depth) 39 | jsonData, jsonMarshalErr := json.Marshal(result) 40 | assert.Nil(t, jsonMarshalErr) 41 | 42 | output := new(strings.Builder) 43 | formatErr := FormatJson(bytes.NewReader(jsonData), output, " ", ColorsDisabled) 44 | assert.Nil(t, formatErr) 45 | assert.Equal(t, expectedJson, output.String()) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /internal/utils/utils.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "encoding/xml" 7 | "errors" 8 | "fmt" 9 | "github.com/PuerkitoBio/goquery" 10 | "github.com/antchfx/xmlquery" 11 | "github.com/antchfx/xpath" 12 | "github.com/fatih/color" 13 | "golang.org/x/net/html" 14 | "golang.org/x/text/encoding/ianaindex" 15 | "golang.org/x/text/transform" 16 | "io" 17 | "os" 18 | "os/exec" 19 | "reflect" 20 | "regexp" 21 | "strconv" 22 | "strings" 23 | ) 24 | 25 | const ( 26 | ColorsDefault = iota 27 | ColorsForced 28 | ColorsDisabled 29 | ) 30 | 31 | type ContentType int 32 | 33 | const ( 34 | ContentXml ContentType = iota 35 | ContentHtml 36 | ContentJson 37 | ContentText 38 | ) 39 | 40 | type QueryOptions struct { 41 | WithTags bool 42 | Indent string 43 | Colors int 44 | } 45 | 46 | const ( 47 | jsonTokenTopValue = iota 48 | jsonTokenArrayStart 49 | jsonTokenArrayValue 50 | jsonTokenArrayComma 51 | jsonTokenObjectStart 52 | jsonTokenObjectKey 53 | jsonTokenObjectColon 54 | jsonTokenObjectValue 55 | jsonTokenObjectComma 56 | ) 57 | 58 | func FormatXml(reader io.Reader, writer io.Writer, indent string, colors int) error { 59 | decoder := xml.NewDecoder(reader) 60 | decoder.Strict = false 61 | decoder.CharsetReader = getCharsetReader 62 | 63 | level := 0 64 | hasContent := false 65 | nsAliases := map[string]string{"http://www.w3.org/XML/1998/namespace": "xml"} 66 | lastTagName := "" 67 | startTagClosed := true 68 | newline := "\n" 69 | if indent == "" { 70 | newline = "" 71 | } 72 | 73 | if ColorsDefault != colors { 74 | color.NoColor = colors == ColorsDisabled 75 | } 76 | 77 | tagColor := color.New(color.FgYellow).SprintFunc() 78 | attrColor := color.New(color.FgGreen).SprintFunc() 79 | commentColor := color.New(color.FgHiBlue).SprintFunc() 80 | 81 | for { 82 | token, err := decoder.Token() 83 | 84 | if err == io.EOF { 85 | break 86 | } 87 | 88 | if err != nil { 89 | return err 90 | } 91 | 92 | switch typedToken := token.(type) { 93 | case xml.ProcInst: 94 | _, _ = fmt.Fprintf(writer, "%s%s", tagColor(""), newline) 104 | case xml.StartElement: 105 | if !startTagClosed { 106 | _, _ = fmt.Fprint(writer, tagColor(">")) 107 | startTagClosed = true 108 | } 109 | if level > 0 { 110 | _, _ = fmt.Fprint(writer, newline, strings.Repeat(indent, level)) 111 | } 112 | var attrs []string 113 | for _, attr := range typedToken.Attr { 114 | if attr.Name.Space == "xmlns" && nsAliases[attr.Value] == "" { 115 | nsAliases[attr.Value] = attr.Name.Local 116 | } 117 | if attr.Name.Local == "xmlns" { 118 | nsAliases[attr.Value] = "" 119 | } 120 | escapedValue, _ := escapeText(attr.Value) 121 | attrElement := getTokenFullName(attr.Name, nsAliases) + attrColor("=\""+escapedValue+"\"") 122 | attrs = append(attrs, attrElement) 123 | } 124 | attrsStr := strings.Join(attrs, " ") 125 | if attrsStr != "" { 126 | attrsStr = " " + attrsStr 127 | } 128 | currentTagName := getTokenFullName(typedToken.Name, nsAliases) 129 | _, _ = fmt.Fprint(writer, tagColor("<"+currentTagName)+attrsStr) 130 | lastTagName = currentTagName 131 | startTagClosed = false 132 | level++ 133 | hasContent = false 134 | case xml.CharData: 135 | str := normalizeSpaces(string(typedToken), indent, level) 136 | hasContent = str != "" 137 | if hasContent && !startTagClosed { 138 | _, _ = fmt.Fprint(writer, tagColor(">")) 139 | startTagClosed = true 140 | } 141 | if hasContent && (strings.Contains(str, "&") || strings.Contains(str, "<")) { 142 | str = "" 143 | } 144 | _, _ = fmt.Fprint(writer, str) 145 | case xml.Comment: 146 | if !startTagClosed { 147 | _, _ = fmt.Fprint(writer, tagColor(">")) 148 | startTagClosed = true 149 | } 150 | 151 | for index, commentLine := range strings.Split(string(typedToken), "\n") { 152 | if !hasContent && level > 0 { 153 | _, _ = fmt.Fprint(writer, newline, strings.Repeat(indent, level)) 154 | } 155 | if index == 0 { 156 | _, _ = fmt.Fprint(writer, commentColor("")) 161 | 162 | if level == 0 { 163 | _, _ = fmt.Fprint(writer, newline) 164 | } 165 | case xml.EndElement: 166 | if level > 0 { 167 | level-- 168 | } 169 | currentTagName := getTokenFullName(typedToken.Name, nsAliases) 170 | if !hasContent { 171 | if lastTagName != currentTagName { 172 | if !startTagClosed { 173 | _, _ = fmt.Fprint(writer, tagColor(">")) 174 | startTagClosed = true 175 | } 176 | _, _ = fmt.Fprint(writer, newline, strings.Repeat(indent, level), tagColor("")) 177 | } else { 178 | _, _ = fmt.Fprint(writer, tagColor("/>")) 179 | startTagClosed = true 180 | } 181 | } else { 182 | _, _ = fmt.Fprint(writer, tagColor("")) 183 | } 184 | hasContent = false 185 | lastTagName = currentTagName 186 | if startTagClosed { 187 | lastTagName = "" 188 | } 189 | case xml.Directive: 190 | _, _ = fmt.Fprint(writer, tagColor("")) 191 | _, _ = fmt.Fprint(writer, newline, strings.Repeat(indent, level)) 192 | default: 193 | } 194 | } 195 | 196 | _, _ = fmt.Fprint(writer, "\n") 197 | 198 | return nil 199 | } 200 | 201 | func XPathQuery(reader io.Reader, writer io.Writer, query string, singleNode bool, options QueryOptions) (errRes error) { 202 | defer func() { 203 | if err := recover(); err != nil { 204 | errRes = fmt.Errorf("XPath error: %v", err) 205 | } 206 | }() 207 | 208 | doc, err := xmlquery.ParseWithOptions(reader, xmlquery.ParserOptions{ 209 | Decoder: &xmlquery.DecoderOptions{ 210 | Strict: false, 211 | CharsetReader: getCharsetReader, 212 | }, 213 | }) 214 | if err != nil { 215 | return err 216 | } 217 | 218 | if singleNode { 219 | if n := xmlquery.FindOne(doc, query); n != nil { 220 | return printNodeContent(writer, n, options) 221 | } 222 | } else if options.WithTags { 223 | for _, n := range xmlquery.Find(doc, query) { 224 | err := printNodeContent(writer, n, options) 225 | if err != nil { 226 | return err 227 | } 228 | } 229 | } else { 230 | expr, _ := xpath.Compile(query) 231 | if expr == nil { 232 | return errors.New("unable to parse the XPath query") 233 | } 234 | 235 | val := expr.Evaluate(xmlquery.CreateXPathNavigator(doc)) 236 | 237 | switch typedVal := val.(type) { 238 | case float64: 239 | _, err = fmt.Fprintf(writer, "%.0f\n", typedVal) 240 | case string: 241 | _, err = fmt.Fprintf(writer, "%s\n", strings.TrimSpace(typedVal)) 242 | case *xpath.NodeIterator: 243 | for typedVal.MoveNext() { 244 | typedVal.Current() 245 | _, err = fmt.Fprintf(writer, "%s\n", strings.TrimSpace(typedVal.Current().Value())) 246 | if err != nil { 247 | break 248 | } 249 | } 250 | default: 251 | return fmt.Errorf("unknown type error: %v", val) 252 | } 253 | 254 | if err != nil { 255 | return err 256 | } 257 | } 258 | 259 | return nil 260 | } 261 | 262 | func printNodeContent(writer io.Writer, node *xmlquery.Node, options QueryOptions) error { 263 | if options.WithTags { 264 | reader := strings.NewReader(node.OutputXML(true)) 265 | return FormatXml(reader, writer, options.Indent, options.Colors) 266 | } 267 | 268 | _, err := fmt.Fprintf(writer, "%s\n", strings.TrimSpace(node.InnerText())) 269 | return err 270 | } 271 | 272 | func CSSQuery(reader io.Reader, writer io.Writer, query string, attr string, options QueryOptions) error { 273 | doc, err := goquery.NewDocumentFromReader(reader) 274 | if err != nil { 275 | return err 276 | } 277 | 278 | doc.Find(query).Each(func(index int, item *goquery.Selection) { 279 | if attr != "" { 280 | _, _ = fmt.Fprintf(writer, "%s\n", strings.TrimSpace(item.AttrOr(attr, ""))) 281 | } else { 282 | if options.WithTags { 283 | node := item.Nodes[0] 284 | tagName := node.Data 285 | var attrs []string 286 | attrsStr := "" 287 | for _, tagAttr := range node.Attr { 288 | escapedValue, _ := escapeText(tagAttr.Val) 289 | attrs = append(attrs, tagAttr.Key+"=\""+escapedValue+"\"") 290 | } 291 | if len(attrs) > 0 { 292 | attrsStr = " " + strings.Join(attrs, " ") 293 | } 294 | html, _ := item.Html() 295 | reader := strings.NewReader(fmt.Sprintf("<%s%s>%s", tagName, attrsStr, html, tagName)) 296 | FormatHtml(reader, writer, options.Indent, options.Colors) 297 | } else { 298 | _, _ = fmt.Fprintf(writer, "%s\n", strings.TrimSpace(item.Text())) 299 | } 300 | } 301 | }) 302 | 303 | return nil 304 | } 305 | 306 | func FormatHtml(reader io.Reader, writer io.Writer, indent string, colors int) error { 307 | tokenizer := html.NewTokenizer(reader) 308 | 309 | if ColorsDefault != colors { 310 | color.NoColor = colors == ColorsDisabled 311 | } 312 | 313 | tagColor := color.New(color.FgYellow).SprintFunc() 314 | attrColor := color.New(color.FgGreen).SprintFunc() 315 | commentColor := color.New(color.FgHiBlue).SprintFunc() 316 | 317 | level := 0 318 | hasContent := false 319 | forceNewLine := false 320 | selfClosingTags := getSelfClosingTags() 321 | newline := "\n" 322 | if indent == "" { 323 | newline = "" 324 | } 325 | 326 | for { 327 | token := tokenizer.Next() 328 | 329 | if token == html.ErrorToken { 330 | err := tokenizer.Err() 331 | if err == io.EOF { 332 | break 333 | } 334 | return err 335 | } 336 | 337 | switch token { 338 | case html.TextToken: 339 | str := normalizeSpaces(string(tokenizer.Text()), indent, level) 340 | hasContent = str != "" 341 | _, _ = fmt.Fprint(writer, str) 342 | case html.StartTagToken, html.SelfClosingTagToken: 343 | if level > 0 { 344 | _, _ = fmt.Fprint(writer, newline, strings.Repeat(indent, level)) 345 | } 346 | 347 | tagName, hasAttr := tokenizer.TagName() 348 | selfClosingTag := token == html.SelfClosingTagToken 349 | 350 | if !selfClosingTag && selfClosingTags[string(tagName)] { 351 | selfClosingTag = true 352 | } 353 | 354 | var attrs []string 355 | attrsStr := "" 356 | 357 | if hasAttr { 358 | for { 359 | attrKey, attrValue, moreAttr := tokenizer.TagAttr() 360 | escapedValue, _ := escapeText(string(attrValue)) 361 | attrs = append(attrs, string(attrKey)+attrColor("=\""+escapedValue+"\"")) 362 | if !moreAttr { 363 | break 364 | } 365 | } 366 | 367 | attrsStr = " " + strings.Join(attrs, " ") 368 | } 369 | 370 | _, _ = fmt.Fprint(writer, tagColor("<"+string(tagName))+attrsStr) 371 | 372 | if selfClosingTag { 373 | _, _ = fmt.Fprint(writer, tagColor("/>")) 374 | } else { 375 | level++ 376 | _, _ = fmt.Fprint(writer, tagColor(">")) 377 | forceNewLine = false 378 | } 379 | case html.EndTagToken: 380 | if level > 0 { 381 | level-- 382 | } 383 | tagName, _ := tokenizer.TagName() 384 | 385 | if forceNewLine { 386 | _, _ = fmt.Fprint(writer, newline, strings.Repeat(indent, level)) 387 | } 388 | _, _ = fmt.Fprint(writer, tagColor("")) 389 | 390 | hasContent = false 391 | forceNewLine = true 392 | case html.DoctypeToken: 393 | docType := tokenizer.Text() 394 | _, _ = fmt.Fprint(writer, tagColor(""), newline) 395 | case html.CommentToken: 396 | for _, commentLine := range strings.Split(string(tokenizer.Raw()), "\n") { 397 | if !hasContent && level > 0 { 398 | _, _ = fmt.Fprint(writer, newline, strings.Repeat(indent, level)) 399 | } 400 | _, _ = fmt.Fprint(writer, commentColor(commentLine)) 401 | } 402 | 403 | if level == 0 { 404 | _, _ = fmt.Fprint(writer, newline) 405 | } 406 | } 407 | } 408 | 409 | _, _ = fmt.Fprint(writer, "\n") 410 | 411 | return nil 412 | } 413 | 414 | func FormatJson(reader io.Reader, writer io.Writer, indent string, colors int) error { 415 | decoder := json.NewDecoder(reader) 416 | decoder.UseNumber() 417 | 418 | if ColorsDefault != colors { 419 | color.NoColor = colors == ColorsDisabled 420 | } 421 | 422 | tagColor := color.New(color.FgYellow).SprintFunc() 423 | attrColor := color.New(color.FgHiBlue).SprintFunc() 424 | valueColor := color.New(color.FgGreen).SprintFunc() 425 | 426 | level := 0 427 | suffix := "" 428 | prefix := "" 429 | newline := "\n" 430 | if indent == "" { 431 | newline = "" 432 | } 433 | 434 | for { 435 | token, err := decoder.Token() 436 | 437 | if err == io.EOF { 438 | break 439 | } 440 | 441 | if err != nil { 442 | return err 443 | } 444 | 445 | v := reflect.ValueOf(*decoder) 446 | tokenState := v.FieldByName("tokenState").Int() 447 | 448 | switch tokenType := token.(type) { 449 | case json.Delim: 450 | switch rune(tokenType) { 451 | case '{': 452 | _, _ = fmt.Fprint(writer, prefix, tagColor("{"), newline) 453 | level++ 454 | suffix = strings.Repeat(indent, level) 455 | case '}': 456 | if level > 0 { 457 | level-- 458 | } 459 | _, _ = fmt.Fprint(writer, newline, strings.Repeat(indent, level), tagColor("}")) 460 | if tokenState == jsonTokenArrayComma { 461 | suffix = "," + newline + strings.Repeat(indent, level) 462 | } 463 | case '[': 464 | _, _ = fmt.Fprint(writer, prefix, tagColor("["), newline) 465 | level++ 466 | suffix = strings.Repeat(indent, level) 467 | case ']': 468 | if level > 0 { 469 | level-- 470 | } 471 | _, _ = fmt.Fprint(writer, newline, strings.Repeat(indent, level), tagColor("]")) 472 | } 473 | case string: 474 | escapedToken := strconv.Quote(token.(string)) 475 | value := valueColor(escapedToken) 476 | if tokenState == jsonTokenObjectColon { 477 | value = attrColor(escapedToken) 478 | } 479 | _, _ = fmt.Fprintf(writer, "%s%s", prefix, value) 480 | case float64: 481 | _, _ = fmt.Fprintf(writer, "%s%v", prefix, valueColor(token)) 482 | case json.Number: 483 | _, _ = fmt.Fprintf(writer, "%s%v", prefix, valueColor(token)) 484 | case bool: 485 | _, _ = fmt.Fprintf(writer, "%s%v", prefix, valueColor(token)) 486 | case nil: 487 | _, _ = fmt.Fprintf(writer, "%s%s", prefix, valueColor("null")) 488 | } 489 | 490 | switch tokenState { 491 | case jsonTokenObjectColon: 492 | suffix = ": " 493 | case jsonTokenObjectComma: 494 | suffix = "," + newline + strings.Repeat(indent, level) 495 | case jsonTokenArrayComma: 496 | suffix = "," + newline + strings.Repeat(indent, level) 497 | } 498 | 499 | prefix = suffix 500 | } 501 | 502 | _, _ = fmt.Fprint(writer, "\n") 503 | 504 | return nil 505 | } 506 | 507 | func IsHTML(input string) bool { 508 | input = strings.ToLower(input) 509 | htmlMarkers := []string{"html", "Some Title"}, 117 | {input: "unformatted8.xml", node: false, single: false, query: "count(//link)", result: "2"}, 118 | } 119 | 120 | for _, testCase := range tests { 121 | fileReader := getFileReader(path.Join("..", "..", "test", "data", "xml", testCase.input)) 122 | output := new(strings.Builder) 123 | options := QueryOptions{WithTags: testCase.node, Indent: " "} 124 | err := XPathQuery(fileReader, output, testCase.query, testCase.single, options) 125 | assert.Nil(t, err) 126 | assert.Equal(t, testCase.result, strings.Trim(output.String(), "\n")) 127 | } 128 | } 129 | 130 | func TestCSSQuery(t *testing.T) { 131 | type test struct { 132 | input string 133 | node bool 134 | query string 135 | attr string 136 | result string 137 | } 138 | 139 | tests := []test{ 140 | {input: "formatted.html", node: false, query: "body > p", attr: "", result: "text"}, 141 | {input: "formatted.html", node: false, query: "script", attr: "src", result: "foo.js\nbar.js\nbaz.js"}, 142 | {input: "formatted.html", node: true, query: "p", attr: "", result: "

text

"}, 143 | {input: "formatted.html", node: true, query: "a", attr: "", result: "link"}, 144 | } 145 | 146 | for _, testCase := range tests { 147 | fileReader := getFileReader(path.Join("..", "..", "test", "data", "html", testCase.input)) 148 | output := new(strings.Builder) 149 | options := QueryOptions{WithTags: testCase.node, Indent: " "} 150 | err := CSSQuery(fileReader, output, testCase.query, testCase.attr, options) 151 | assert.Nil(t, err) 152 | assert.Equal(t, testCase.result, strings.Trim(output.String(), "\n")) 153 | } 154 | } 155 | 156 | func TestIsHTML(t *testing.T) { 157 | assert.True(t, IsHTML("")) 158 | assert.True(t, IsHTML("")) 159 | assert.True(t, IsHTML(" ...")) 160 | 161 | assert.False(t, IsHTML("")) 162 | assert.False(t, IsHTML("")) 163 | } 164 | 165 | func TestIsJSON(t *testing.T) { 166 | assert.True(t, IsJSON(`{"key": "value"}`)) 167 | assert.True(t, IsJSON(`{"key": "value", "key2": "value2"}`)) 168 | assert.True(t, IsJSON(`[1, 2, 3]`)) 169 | assert.True(t, IsJSON(` {}`)) 170 | assert.False(t, IsJSON(``)) 171 | } 172 | 173 | func TestPagerPrint(t *testing.T) { 174 | var output bytes.Buffer 175 | fileReader := getFileReader(path.Join("..", "..", "test", "data", "html", "formatted.html")) 176 | err := PagerPrint(fileReader, &output) 177 | assert.Nil(t, err) 178 | assert.Contains(t, output.String(), "") 179 | } 180 | 181 | func TestEscapeText(t *testing.T) { 182 | result, err := escapeText("\"value\"") 183 | assert.Nil(t, err) 184 | assert.Equal(t, ""value"", result) 185 | } 186 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | _ "embed" 5 | "fmt" 6 | "github.com/sibprogrammer/xq/cmd" 7 | "strings" 8 | ) 9 | 10 | var ( 11 | commit = "000000" 12 | date = "" 13 | ) 14 | 15 | //go:embed version 16 | var version string 17 | 18 | func main() { 19 | fullVersion := strings.TrimSpace(version) 20 | if date != "" { 21 | fullVersion += fmt.Sprintf(" (%s, %s)", date, commit) 22 | } 23 | cmd.Version = fullVersion 24 | cmd.Execute() 25 | } 26 | -------------------------------------------------------------------------------- /scripts/install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | URL_PREFIX="https://github.com/sibprogrammer/xq" 6 | INSTALL_DIR=/usr/local/bin/ 7 | BINARY=xq 8 | LATEST_VERSION=$(curl -L -s -H 'Accept: application/json' $URL_PREFIX/releases/latest | sed -e 's/.*"tag_name":"v\([^"]*\)".*/\1/') 9 | PLATFORM=$(uname -s | tr A-Z a-z) 10 | case "$(uname -m)" in 11 | arm64) 12 | ARCH=arm64 13 | ;; 14 | aarch64) 15 | ARCH=arm64 16 | ;; 17 | armv6l) 18 | ARCH=armv6 19 | ;; 20 | armv7l) 21 | ARCH=armv7 22 | ;; 23 | *) 24 | ARCH=amd64 25 | ;; 26 | esac 27 | ARCHIVE="${BINARY}_${LATEST_VERSION}_${PLATFORM}_${ARCH}.tar.gz" 28 | URL="$URL_PREFIX/releases/download/v${LATEST_VERSION}/$ARCHIVE" 29 | 30 | echo "Installation of $BINARY" 31 | rm -f $INSTALL_DIR$BINARY 32 | curl -sSL "$URL" | tar xz -C $INSTALL_DIR $BINARY 33 | chmod +x $INSTALL_DIR$BINARY 34 | echo "Successfully installed at $INSTALL_DIR$BINARY" 35 | -------------------------------------------------------------------------------- /test/data/config/config1: -------------------------------------------------------------------------------- 1 | # config 2 | indent = 8 3 | no-color=1 4 | -------------------------------------------------------------------------------- /test/data/config/config2: -------------------------------------------------------------------------------- 1 | # empty config 2 | -------------------------------------------------------------------------------- /test/data/html/formatted.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Test 4 | 5 | 6 | 7 | 8 | 9 | 10 |

text

11 | link 12 | 13 | 14 | -------------------------------------------------------------------------------- /test/data/html/formatted.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | John 5 | Smith 6 |
7 | 1234 Main Road 8 | Bellville 9 |
10 |
11 | -------------------------------------------------------------------------------- /test/data/html/formatted2.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | HTML 5 Boilerplate 8 | 9 | 10 | 11 | link here 12 | 13 | 14 | -------------------------------------------------------------------------------- /test/data/html/formatted3.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 |

8 | blah (blah) 9 |

10 | 11 | 12 | -------------------------------------------------------------------------------- /test/data/html/formatted4.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 |

world

7 | 8 | 9 | -------------------------------------------------------------------------------- /test/data/html/formatted5.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /test/data/html/formatted6.html: -------------------------------------------------------------------------------- 1 | 2 |

3 | Welcome 4 |

5 |

6 | Here is no content, yet. 7 |

8 | 9 | -------------------------------------------------------------------------------- /test/data/html/unformatted.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Test 4 | 5 | 6 | 7 | 8 | 9 | 10 |

text

11 | link 12 | 13 | 14 | -------------------------------------------------------------------------------- /test/data/html/unformatted.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | JohnSmith
4 | 1234 Main RoadBellville
5 | -------------------------------------------------------------------------------- /test/data/html/unformatted2.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | HTML 5 Boilerplate 8 | 9 | 10 | 11 | link here 12 | 13 | 14 | -------------------------------------------------------------------------------- /test/data/html/unformatted3.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 |

blah (blah)

8 | 9 | 10 | -------------------------------------------------------------------------------- /test/data/html/unformatted4.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 |

world

7 | 8 | 9 | -------------------------------------------------------------------------------- /test/data/html/unformatted5.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /test/data/html/unformatted6.html: -------------------------------------------------------------------------------- 1 | 2 |

3 | Welcome 4 |

5 |

6 | Here is no content, yet. 7 |

8 | 9 | -------------------------------------------------------------------------------- /test/data/json/formatted.json: -------------------------------------------------------------------------------- 1 | { 2 | "menu": { 3 | "id": "file", 4 | "value": 17, 5 | "price": 100.32, 6 | "popup": { 7 | "menuitem": [ 8 | { 9 | "value": "New", 10 | "onclick": "CreateNewDoc()" 11 | }, 12 | { 13 | "value": "Open", 14 | "onclick": "OpenDoc()", 15 | "new": true 16 | }, 17 | { 18 | "value": "Close", 19 | "onclick": "CloseDoc()", 20 | "link": null 21 | } 22 | ] 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /test/data/json/formatted2.json: -------------------------------------------------------------------------------- 1 | { 2 | "fields": { 3 | "fixVersions": [ 4 | "1.0.0" 5 | ], 6 | "customfield_10473": null, 7 | "customfield_10474": null 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /test/data/json/formatted3.json: -------------------------------------------------------------------------------- 1 | { 2 | "key": "string \"with\" quotes" 3 | } 4 | -------------------------------------------------------------------------------- /test/data/json/unformatted.json: -------------------------------------------------------------------------------- 1 | {"menu": { 2 | "id": "file" , 3 | "value": 17, 4 | "price": 100.32, 5 | "popup": { 6 | "menuitem": [ 7 | {"value": "New", "onclick": "CreateNewDoc()"}, 8 | {"value": "Open", "onclick": "OpenDoc()", "new": true}, 9 | {"value": "Close", "onclick": "CloseDoc()", "link": null } 10 | ] 11 | } 12 | }} 13 | -------------------------------------------------------------------------------- /test/data/json/unformatted2.json: -------------------------------------------------------------------------------- 1 | { 2 | "fields": { 3 | "fixVersions": [ 4 | "1.0.0" 5 | ], 6 | "customfield_10473": null, 7 | "customfield_10474": null 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /test/data/json/unformatted3.json: -------------------------------------------------------------------------------- 1 | { "key": "string \"with\" quotes" } 2 | -------------------------------------------------------------------------------- /test/data/xml/formatted.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | John 5 | Smith 6 |
7 | 1234 Main Road 8 | Bellville 9 |
10 |
11 | -------------------------------------------------------------------------------- /test/data/xml/formatted10.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /test/data/xml/formatted11.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 |

4 | Welcome 5 |

6 |

7 | Here is no content, yet. 8 |

9 | 10 | -------------------------------------------------------------------------------- /test/data/xml/formatted12.xml: -------------------------------------------------------------------------------- 1 | 2 | value 3 | 4 | -------------------------------------------------------------------------------- /test/data/xml/formatted13.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | event seen: 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /test/data/xml/formatted14.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /test/data/xml/formatted15.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /test/data/xml/formatted16.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /test/data/xml/formatted2.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /test/data/xml/formatted3.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | http://site1.ru/page1.html 6 | 1.1.1.1 7 | 8 | 9 | 10 | site2.ru 11 | 2.2.2.2 12 | 3.3.3.3 13 | 14 | 15 | -------------------------------------------------------------------------------- /test/data/xml/formatted4.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /test/data/xml/formatted5.xml: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /test/data/xml/formatted6.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /test/data/xml/formatted7.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 |

world

6 | 7 | -------------------------------------------------------------------------------- /test/data/xml/formatted8.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Some Title 5 | Public posts from ... 6 | https://mastodon.social/@some-user 7 | 8 | https://files... 9 | User Name 10 | https://mastodon... 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /test/data/xml/formatted9.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Good Will Hunting 5 | 6 | -------------------------------------------------------------------------------- /test/data/xml/unformatted.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | JohnSmith
4 | 1234 Main RoadBellville
5 | -------------------------------------------------------------------------------- /test/data/xml/unformatted10.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /test/data/xml/unformatted11.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 |

4 | Welcome 5 |

6 |

7 | Here is no content, yet. 8 |

9 | 10 | -------------------------------------------------------------------------------- /test/data/xml/unformatted12.xml: -------------------------------------------------------------------------------- 1 | 2 | value 3 | 4 | -------------------------------------------------------------------------------- /test/data/xml/unformatted13.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | event seen: 5 | 6 | 7 | -------------------------------------------------------------------------------- /test/data/xml/unformatted14.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /test/data/xml/unformatted15.xml: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /test/data/xml/unformatted16.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /test/data/xml/unformatted2.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /test/data/xml/unformatted3.xml: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sibprogrammer/xq/50820fb1825e7f1a4e317dec462e58751d1688a3/test/data/xml/unformatted3.xml -------------------------------------------------------------------------------- /test/data/xml/unformatted4.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /test/data/xml/unformatted5.xml: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /test/data/xml/unformatted6.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /test/data/xml/unformatted7.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 |

world

6 | 7 | -------------------------------------------------------------------------------- /test/data/xml/unformatted8.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Some Title 5 | Public posts from ... 6 | https://mastodon.social/@some-user 7 | 8 | https://files... 9 | User Name 10 | https://mastodon... 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /test/data/xml/unformatted9.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Good Will Hunting 5 | 6 | -------------------------------------------------------------------------------- /test/data/xml2json/formatted.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": { 3 | "child": "value" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /test/data/xml2json/formatted2.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": { 3 | "@attr": "value", 4 | "child": "text" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /test/data/xml2json/formatted3.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": { 3 | "#text": "text\nmore text", 4 | "child": "value" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /test/data/xml2json/formatted4.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": { 3 | "child1": "value", 4 | "child2": "text" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /test/data/xml2json/unformatted.xml: -------------------------------------------------------------------------------- 1 | 2 | value 3 | 4 | -------------------------------------------------------------------------------- /test/data/xml2json/unformatted2.xml: -------------------------------------------------------------------------------- 1 | 2 | text 3 | 4 | -------------------------------------------------------------------------------- /test/data/xml2json/unformatted3.xml: -------------------------------------------------------------------------------- 1 | 2 | text value 3 | more text 4 | 5 | -------------------------------------------------------------------------------- /test/data/xml2json/unformatted4.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | value 4 | 5 | text 6 | 7 | -------------------------------------------------------------------------------- /test/pipe-test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | FILE=$1 4 | 5 | while read -r LINE; do 6 | echo $LINE 7 | sleep 1 8 | done < $FILE 9 | -------------------------------------------------------------------------------- /version: -------------------------------------------------------------------------------- 1 | 1.3.1 2 | --------------------------------------------------------------------------------