├── .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 | [](https://github.com/sibprogrammer/xq/actions)
4 | [](https://goreportcard.com/report/github.com/sibprogrammer/xq)
5 | [](https://codecov.io/gh/sibprogrammer/xq)
6 | [](https://github.com/sibprogrammer/xq/)
7 | [](https://formulae.brew.sh/formula/xq)
8 | [](https://repology.org/project/xq-sibprogrammer/versions)
9 |
10 | Command-line XML and HTML beautifier and content extractor.
11 |
12 | 
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(""), typedToken.Target)
95 |
96 | pi := strings.TrimSpace(string(typedToken.Inst))
97 | attrs := strings.Split(pi, " ")
98 | for _, attr := range attrs {
99 | attrComponents := strings.SplitN(attr, "=", 2)
100 | _, _ = fmt.Fprintf(writer, " %s%s", attrComponents[0], attrColor("="+attrComponents[1]))
101 | }
102 |
103 | _, _ = fmt.Fprint(writer, 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(""+currentTagName+">"))
177 | } else {
178 | _, _ = fmt.Fprint(writer, tagColor("/>"))
179 | startTagClosed = true
180 | }
181 | } else {
182 | _, _ = fmt.Fprint(writer, tagColor(""+currentTagName+">"))
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%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(""+string(tagName)+">"))
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 |
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 |
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 |
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 |
--------------------------------------------------------------------------------