Repository: antonmedv/fx Branch: master Commit: 56d0b9be19ee Files: 103 Total size: 518.4 KB Directory structure: gitextract_4yli86d0/ ├── .gitattributes ├── .github/ │ ├── images/ │ │ ├── autocomplete.tape │ │ ├── preview-mode.tape │ │ └── preview.tape │ ├── stream.mjs │ └── workflows/ │ ├── brew.yml │ ├── docker.yml │ ├── snap.yml │ └── test.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── RELEASE.md ├── go.mod ├── go.sum ├── help.go ├── internal/ │ ├── complete/ │ │ ├── complete.bash │ │ ├── complete.fish │ │ ├── complete.go │ │ ├── complete.zsh │ │ ├── complete_test.go │ │ ├── prelude.js │ │ └── utils.go │ ├── engine/ │ │ ├── engine.go │ │ ├── engine_test.go │ │ ├── format_err.go │ │ ├── fxrc.go │ │ ├── quote.go │ │ ├── quote_test.go │ │ ├── slurp.go │ │ ├── stdlib.js │ │ ├── stdlib_test.go │ │ ├── stringify.go │ │ ├── transpile.go │ │ ├── transpile_test.go │ │ ├── utils.go │ │ └── vm.go │ ├── fuzzy/ │ │ ├── algo.go │ │ ├── chars.go │ │ ├── chars_test.go │ │ ├── find.go │ │ ├── fuzzy_test.go │ │ ├── normalize.go │ │ └── utils.go │ ├── ident/ │ │ └── ident.go │ ├── jsonpath/ │ │ ├── path.go │ │ ├── path_test.go │ │ ├── ref.go │ │ └── ref_test.go │ ├── jsonx/ │ │ ├── delete.go │ │ ├── delete_test.go │ │ ├── format_err.go │ │ ├── json.go │ │ ├── jsonx_test.go │ │ ├── line.go │ │ ├── node.go │ │ ├── node_test.go │ │ ├── string.go │ │ ├── to_value.go │ │ └── wrap.go │ ├── pretty/ │ │ ├── inlineable.go │ │ ├── inlineable_test.go │ │ ├── pretty_print.go │ │ └── pretty_print_test.go │ ├── shlex/ │ │ ├── shlex.go │ │ └── shlex_test.go │ ├── theme/ │ │ └── theme.go │ ├── toml/ │ │ ├── toml.go │ │ └── toml_test.go │ └── utils/ │ ├── image.go │ ├── life.go │ ├── utils.go │ └── utils_test.go ├── keymap.go ├── main.go ├── main_test.go ├── npm/ │ ├── README.md │ ├── index.js │ ├── package.json │ └── test.js ├── preview.go ├── scripts/ │ └── build.mjs ├── search.go ├── search_test.go ├── snap/ │ └── snapcraft.yaml ├── testdata/ │ ├── TestCollapseRecursive.golden │ ├── TestCollapseRecursiveWithSizes.golden │ ├── TestGotoLine.golden │ ├── TestGotoLineCollapsed.golden │ ├── TestGotoLineInputGreaterThanTotalLines.golden │ ├── TestGotoLineInputInvalid.golden │ ├── TestGotoLineInputLessThanOne.golden │ ├── TestGotoLineKeepsHistory.golden │ ├── TestNavigation.golden │ ├── TestOutput.golden │ ├── blog.json │ └── example.json ├── utils.go ├── version.go ├── view.go ├── vim.go └── vim_test.go ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitattributes ================================================ *.golden -text ================================================ FILE: .github/images/autocomplete.tape ================================================ Output autocomplete.gif Output autocomplete.mp4 Set Shell zsh Set FontSize 32 Set Width 1800 Set Height 400 Set TypingSpeed 200ms Hide Type ". ~/.zshrc && clear" Enter Show Type "fx example.json " Sleep 1s Type "." Tab@500ms 2 Sleep 1s Tab Sleep 500ms Tab Sleep 500ms Tab Sleep 500ms Tab Sleep 500ms Tab Sleep 500ms Tab Sleep 1s Type "." Tab@500ms 2 Sleep 500ms Tab Sleep 2s Enter Sleep 2s ================================================ FILE: .github/images/preview-mode.tape ================================================ Output preview-mode.gif Output preview-mode.mp4 Set FontSize 32 Set Width 1800 Set Height 1200 Set TypingSpeed 200ms Hide Type "fx testdata/blog.json" Enter Show Sleep 1s Down Sleep 1s Down Sleep 1s Type "p" Sleep 1s Down Sleep 300ms Down Sleep 300ms Down Sleep 300ms Down Sleep 300ms Down Sleep 300ms Down Sleep 300ms Down Sleep 4s Type "p" Sleep 1s Up Sleep 1s Up ================================================ FILE: .github/images/preview.tape ================================================ Output preview.gif Output preview.mp4 Set FontSize 32 Set Width 1800 Set Height 1200 Set TypingSpeed 200ms Hide Type "fx testdata/example.json" Enter Show Sleep 1s Down Sleep 1s Down Sleep 1s Down 6 Sleep 1s Left Sleep 1s Down Sleep 1s Down Sleep 1s Down Sleep 1s Left Sleep 1s Up 12 Sleep 1s ================================================ FILE: .github/stream.mjs ================================================ #!/usr/bin/env zx process.on('SIGPIPE', () => process.exit(0)) process.on('SIGINT', () => process.exit(0)) process.on('SIGTERM', () => process.exit(0)) process.stdout.on('error', (err) => { if (err.code === 'EPIPE') process.exit(0) throw err }) const names = ['Alice', 'Bob', 'Charlie', 'Diana', 'Eve', 'Frank', 'Grace', 'Henry'] const cities = ['New York', 'London', 'Tokyo', 'Paris', 'Berlin', 'Sydney', 'Toronto', 'Mumbai'] const colors = ['red', 'green', 'blue', 'yellow', 'purple', 'orange', 'pink', 'cyan'] const departments = ['Engineering', 'Sales', 'Marketing', 'HR', 'Finance', 'Operations'] const skills = ['JavaScript', 'Python', 'Go', 'Rust', 'SQL', 'Docker', 'Kubernetes', 'AWS'] function randomItem(arr) { return arr[Math.floor(Math.random() * arr.length)] } function randomItems(arr, min = 1, max = 3) { const count = min + Math.floor(Math.random() * (max - min + 1)) const shuffled = [...arr].sort(() => Math.random() - 0.5) return shuffled.slice(0, count) } function randomObject() { return { id: Math.floor(Math.random() * 1000000), name: randomItem(names), active: Math.random() > 0.5, timestamp: new Date().toISOString(), profile: { age: 20 + Math.floor(Math.random() * 40), city: randomItem(cities), preferences: { color: randomItem(colors), notifications: Math.random() > 0.5, theme: Math.random() > 0.5 ? 'dark' : 'light', }, }, work: { department: randomItem(departments), salary: Math.floor(50000 + Math.random() * 100000), skills: randomItems(skills, 2, 5), }, scores: Array.from({length: 3}, () => Math.round(Math.random() * 100)), tags: randomItems(colors, 1, 3), } } const randomTexts = [ 'Processing records...', 'Loading next batch', '--- checkpoint ---', 'Fetching data from server', 'INFO: Connection stable', 'DEBUG: Buffer flushed', 'Waiting for response...', '>> sync complete', ] const count = parseInt(argv._[0]) || Infinity const delay = parseInt(argv.delay) || 100 const withText = argv['with-text'] || argv.withText for (let i = 0; i < count; i++) { if (withText && Math.random() < 0.15) { console.log(randomItem(randomTexts)) if (delay > 0) await sleep(delay) } console.log(JSON.stringify(randomObject())) if (delay > 0 && i < count - 1) { await sleep(delay) } } ================================================ FILE: .github/workflows/brew.yml ================================================ name: brew on: [workflow_dispatch] jobs: brew: runs-on: macos-latest steps: - name: Set up Homebrew id: set-up-homebrew uses: Homebrew/actions/setup-homebrew@master with: test-bot: false - name: Install Homebrew Bundler RubyGems run: brew install-bundler-gems - name: Configure Git user uses: Homebrew/actions/git-user-config@master - name: Update brew run: brew update - name: Bump formulae uses: Homebrew/actions/bump-packages@master with: token: ${{ secrets.MY_HOMEBREW_RELEASE_GITHUB_TOKEN }} formulae: | fx ================================================ FILE: .github/workflows/docker.yml ================================================ name: docker on: [workflow_dispatch] jobs: docker: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 - name: Login to Docker Hub uses: docker/login-action@v3 with: username: antonmedv password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Build and push uses: docker/build-push-action@v5 with: context: . file: ./Dockerfile push: true platforms: linux/amd64,linux/arm64 tags: antonmedv/fx:latest ================================================ FILE: .github/workflows/snap.yml ================================================ name: snap on: [workflow_dispatch] jobs: snap: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 with: ref: master - name: Set up Go uses: actions/setup-go@v3 with: go-version: 1.21 - uses: snapcore/action-build@v1 id: build - uses: snapcore/action-publish@v1 env: SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.MY_SNAPCRAFT_CREDENTIALS }} with: snap: ${{ steps.build.outputs.snap }} release: stable ================================================ FILE: .github/workflows/test.yml ================================================ name: test on: push: branches: [ master ] pull_request: branches: [ master ] jobs: go: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - uses: actions/setup-go@v3 with: go-version: 1.21 - name: Test run: go test ./... go-arm: runs-on: ubuntu-24.04-arm steps: - uses: actions/checkout@v3 - uses: actions/setup-go@v3 with: go-version: 1.21 - name: Test on ARM run: go test ./... node: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - name: Test NPM version run: cd npm && node test.js ================================================ FILE: .gitignore ================================================ *.prof fx fx.exe ================================================ FILE: Dockerfile ================================================ FROM golang:latest as builder WORKDIR /go COPY go.mod go.sum ./ RUN go mod download COPY . . RUN CGO_ENABLED=0 go build -o fx . FROM alpine COPY --from=builder /go/fx /bin/fx WORKDIR /data ENV COLORTERM=truecolor ENTRYPOINT ["/bin/fx"] ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2018 Anton Medvedev Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # f(x)

fx preview

## Documentation See full documentation at [fx.wtf](https://fx.wtf). ## Related - [walk](https://github.com/antonmedv/walk) – terminal file manager - [howto](https://github.com/antonmedv/howto) – terminal command LLM helper - [countdown](https://github.com/antonmedv/countdown) – terminal countdown timer ## License [MIT](LICENSE)

Join Crow Watch

================================================ FILE: RELEASE.md ================================================ # Release 1. Bump version in [version.go](version.go). 2. Bump version in [snapcraft.yaml](snap/snapcraft.yaml). 3. Bump version in [package.json](npm/package.json). 4. Commit changes. 5. Publish npm package. 6. Trigger [GitHub Actions](https://github.com/antonmedv/fx/actions) (brew, snap, docker). 7. Create a new release on [GitHub](https://github.com/antonmedv/fx/releases/new). 8. Run [build.mjs](scripts/build.mjs) to upload binaries to the release. ```sh npx zx scripts/build.mjs ``` 9. Bump version in [install.sh](https://github.com/antonmedv/fx.wtf/blob/master/public/install.sh) and upload it to [fx.wtf](https://fx.wtf). ================================================ FILE: go.mod ================================================ module github.com/antonmedv/fx go 1.23.0 toolchain go1.23.6 require ( github.com/antonmedv/clipboard v1.0.1 github.com/charmbracelet/bubbles v0.21.0 github.com/charmbracelet/bubbletea v1.3.6 github.com/charmbracelet/lipgloss v1.1.0 github.com/charmbracelet/x/exp/teatest v0.0.0-20231025135604-4a717d4fb812 github.com/charmbracelet/x/term v0.2.1 github.com/dop251/goja v0.0.0-20250630131328-58d95d85e994 github.com/goccy/go-yaml v1.18.0 github.com/mattn/go-isatty v0.0.20 github.com/mattn/go-runewidth v0.0.16 github.com/muesli/termenv v0.16.0 github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 github.com/pelletier/go-toml/v2 v2.2.3 github.com/rivo/uniseg v0.4.7 github.com/stretchr/testify v1.9.0 ) require ( github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymanbagabas/go-udiff v0.2.0 // indirect github.com/charmbracelet/colorprofile v0.3.1 // indirect github.com/charmbracelet/x/ansi v0.10.1 // indirect github.com/charmbracelet/x/cellbuf v0.0.13 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/dlclark/regexp2 v1.11.5 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect github.com/google/pprof v0.0.0-20250630185457-6e76a2b096b5 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-localereader v0.0.1 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/sync v0.16.0 // indirect golang.org/x/sys v0.35.0 // indirect golang.org/x/text v0.27.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) ================================================ FILE: go.sum ================================================ github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0= github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= github.com/antonmedv/clipboard v1.0.1 h1:z9rRBhSKt4lDb6uNcMykUmNbspk/6v07JeiTaOfYYOY= github.com/antonmedv/clipboard v1.0.1/go.mod h1:3jcOUCdraVHehZaOsMaJZoE92MxURt5fovC1gDAiZ2s= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU= github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc= github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40= github.com/charmbracelet/colorprofile v0.3.1/go.mod h1:/GkGusxNs8VB/RSOh3fu0TJmQ4ICMMPApIIVn0KszZ0= github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= github.com/charmbracelet/x/exp/teatest v0.0.0-20231025135604-4a717d4fb812 h1:W/hU7Z+y+QsZo2qg0hwjv56qSMP12Z72DJR8k+ULbA4= github.com/charmbracelet/x/exp/teatest v0.0.0-20231025135604-4a717d4fb812/go.mod h1:TckAxPtan3aJ5wbTgBkySpc50SZhXJRZ8PtYICnZJEw= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dop251/goja v0.0.0-20250630131328-58d95d85e994 h1:aQYWswi+hRL2zJqGacdCZx32XjKYV8ApXFGntw79XAM= github.com/dop251/goja v0.0.0-20250630131328-58d95d85e994/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/go-sourcemap/sourcemap v2.1.4+incompatible h1:a+iTbH5auLKxaNwQFg0B+TCYl6lbukKPc7b5x0n1s6Q= github.com/go-sourcemap/sourcemap v2.1.4+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg= github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/google/pprof v0.0.0-20250630185457-6e76a2b096b5 h1:xhMrHhTJ6zxu3gA4enFM9MLn9AY7613teCdFnlUVbSQ= github.com/google/pprof v0.0.0-20250630185457-6e76a2b096b5/go.mod h1:5hDyRhoBCxViHszMt12TnOpEI4VVi+U8Gm9iphldiMA= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= ================================================ FILE: help.go ================================================ package main import ( "fmt" "os" "reflect" "strings" "time" "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/lipgloss" ) func usage() string { title := lipgloss.NewStyle().Bold(true) return fmt.Sprintf(` %v Terminal JSON viewer %v fx data.json fx data.json .field curl ... | fx %v -h, --help print help -v, --version print version --themes print themes --comp print completion script -r, --raw treat input as a raw string -s, --slurp read all inputs into an array --yaml parse input as YAML --toml parse input as TOML --strict strict mode --no-inline disable inlining in output --game-of-life play the game of life %v https://fx.wtf %v Anton Medvedev `, title.Render("fx "+version), title.Render("Usage"), title.Render("Flags"), title.Render("More info"), title.Render("Author"), ) } var categoryOrder = []string{ "Navigation", "Expand / Collapse", "Search", "Actions", "View", "Other", } func help(keyMap KeyMap) string { titleStyle := lipgloss.NewStyle(). Bold(true) categoryStyle := lipgloss.NewStyle(). Foreground(lipgloss.Color("8")). MarginTop(1) keyStyle := lipgloss.NewStyle() descStyle := lipgloss.NewStyle(). Foreground(lipgloss.Color("8")) dimStyle := lipgloss.NewStyle(). Foreground(lipgloss.Color("8")) // Group bindings by category using struct tags v := reflect.ValueOf(keyMap) t := v.Type() categories := make(map[string][]key.Binding) for _, field := range reflect.VisibleFields(t) { category := field.Tag.Get("category") if category == "" { continue } binding := v.FieldByName(field.Name).Interface().(key.Binding) categories[category] = append(categories[category], binding) } var sb strings.Builder // Header sb.WriteString("\n") sb.WriteString(titleStyle.Render(" Key Bindings")) sb.WriteString("\n") sb.WriteString(dimStyle.Render(" ─────────────────────────────────────────")) sb.WriteString("\n") for _, cat := range categoryOrder { bindings, ok := categories[cat] if !ok || len(bindings) == 0 { continue } sb.WriteString(categoryStyle.Render(" " + cat)) sb.WriteString("\n") for _, binding := range bindings { keyStr := binding.Help().Key if len(keyStr) == 0 { keys := binding.Keys() if len(keys) > 5 { keyStr = fmt.Sprintf("%v-%v", keys[0], keys[len(keys)-1]) } else { keyStr = strings.Join(keys, ", ") } } desc := binding.Help().Desc keyFormatted := keyStyle.Render(fmt.Sprintf("%-20s", keyStr)) descFormatted := descStyle.Render(desc) sb.WriteString(fmt.Sprintf(" %s %s\n", keyFormatted, descFormatted)) } } sb.WriteString("\n") sb.WriteString(dimStyle.Render(" ─────────────────────────────────────────")) sb.WriteString("\n") sb.WriteString(dimStyle.Render(" Press q or ? to close")) sb.WriteString("\n") return sb.String() } func exit() { if showLetter(time.Now()) { style := lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).Padding(1, 2) _, _ = fmt.Fprintln(os.Stderr, style.Render(`Hello, kind human. :) This is fx speaking. I know you’re busy, and I won’t take much of your time. Every day, I quietly sit in your terminal, helping you explore and shape your data. No popups, no ads, quiet, helpful work. But today is different. Today, I’m asking for something small in return. Just for today. If fx has saved you time, solved a problem, or simply made your life in the terminal a little easier, please consider supporting the developer who made me: https://github.com/sponsors/antonmedv He built fx as a passion project, shared it freely with the world, and has kept improving it—all without asking much. Your support helps keep fx alive, maintained, and improving. Even a small donation means a lot. It shows that you care, that this kind of work matters. This message only appears once, on the first Tuesday of December. Tomorrow I’ll be silent again. Thank you for reading. And thank you for using fx.`)) } } func showLetter(t time.Time) bool { if t.Month() != time.December { return false } firstOfDecember := time.Date(t.Year(), time.December, 1, 0, 0, 0, 0, t.Location()) offset := (int(time.Tuesday) - int(firstOfDecember.Weekday()) + 7) % 7 firstTuesday := firstOfDecember.AddDate(0, 0, offset) return t.Year() == firstTuesday.Year() && t.Month() == firstTuesday.Month() && t.Day() == firstTuesday.Day() } ================================================ FILE: internal/complete/complete.bash ================================================ complete -o filenames -C fx fx ================================================ FILE: internal/complete/complete.fish ================================================ complete --command fx --arguments '(COMP_FISH=(commandline -cp) fx)' ================================================ FILE: internal/complete/complete.go ================================================ package complete import ( _ "embed" "encoding/json" "fmt" "os" "path/filepath" "regexp" "strings" "time" "github.com/dop251/goja" "github.com/goccy/go-yaml" "github.com/pelletier/go-toml/v2" "github.com/antonmedv/fx/internal/engine" "github.com/antonmedv/fx/internal/jsonx" "github.com/antonmedv/fx/internal/shlex" ) type Reply struct { Display string Value string Type string // "file" for files, others optional } var Flags []Reply //go:embed complete.bash var Bash string //go:embed complete.zsh var Zsh string //go:embed complete.fish var Fish string //go:embed prelude.js var prelude string func Complete() bool { compLine, ok := os.LookupEnv("COMP_LINE") if ok && len(os.Args) >= 3 { doComplete(compLine, os.Args[2], false) return true } compZsh, ok := os.LookupEnv("COMP_ZSH") if ok { doComplete(compZsh, lastWord(compZsh), true) return true } compFish, ok := os.LookupEnv("COMP_FISH") if ok { doComplete(compFish, lastWord(compFish), false) return true } return false } func doComplete(compLine string, compWord string, withDisplay bool) { if strings.HasPrefix(compWord, "-") { compReply(filterReply(Flags, compWord), withDisplay) return } args, err := shlex.Split(compLine) if err != nil { return } compWord = shlex.Parse(compWord) var flagYaml bool var flagToml bool for _, arg := range args { if arg == "--yaml" { flagYaml = true } if arg == "--toml" { flagToml = true } } // Remove Flags from args. args = filterArgs(args) isSecondArgIsFile := false if len(args) == 0 { return } else if len(args) == 1 { reply := fileComplete(compWord) compReply(reply, withDisplay) return } else if len(args) == 2 { isSecondArgIsFile = isFile(args[1]) if !isSecondArgIsFile { reply := fileComplete(compWord) compReply(reply, withDisplay) return } } else { isSecondArgIsFile = isFile(args[1]) } var reply []Reply if isSecondArgIsFile { file := args[1] hasYamlExt, _ := regexp.MatchString(`(?i)\.ya?ml$`, file) hasTomlExt, _ := regexp.MatchString(`(?i)\.toml$`, file) if !flagYaml && hasYamlExt { flagYaml = true } if !flagToml && hasTomlExt { flagToml = true } if strings.HasPrefix(file, "~") { home, err := os.UserHomeDir() if err == nil { file = filepath.Join(home, file[1:]) } } resultCh := make(chan []Reply, 1) go func() { input, err := os.ReadFile(file) if err != nil { resultCh <- []Reply{} return } if flagYaml { input, err = yaml.YAMLToJSON(input) if err != nil { resultCh <- []Reply{} return } } else if flagToml { var v any if err := toml.Unmarshal(input, &v); err != nil { resultCh <- []Reply{} return } b, err := json.Marshal(v) if err != nil { resultCh <- []Reply{} return } input = b } node, err := jsonx.Parse(input) if err != nil { resultCh <- []Reply{} return } resultCh <- KeysComplete(node, args, compWord) }() select { case result := <-resultCh: reply = append(reply, result...) case <-time.After(3 * time.Second): return } } reply = filterReply(reply, compWord) if len(reply) > 0 { compReply(reply, withDisplay) return } if len(compWord) > 0 { // Only show globals if compWord is not empty, // as we do not want to be very verbose and show all globals. compReply(filterReply(globalsComplete(), compWord), withDisplay) } } func globalsComplete() []Reply { var code strings.Builder code.WriteString(prelude) code.WriteString(engine.Stdlib) code.WriteString("\n__autocomplete()\n") vm := goja.New() value, err := vm.RunString(code.String()) if err != nil { return nil } if array, ok := value.Export().([]any); ok { var reply []Reply for _, key := range array { reply = append(reply, Reply{ Display: key.(string), Value: key.(string), Type: "global", }) } return reply } return nil } func KeysComplete(input *jsonx.Node, args []string, compWord string) []Reply { args = args[2:] // Drop binary & file from the args. if compWord == "" { args = append(args, ".__keys()") } else { if len(args) > 0 { last := args[len(args)-1] last = dropTail(args[len(args)-1]) last = last + ".__keys()" last = balanceBrackets(last) args[len(args)-1] = last } } var code strings.Builder code.WriteString(prelude) code.WriteString(engine.Stdlib) code.WriteString(engine.JS(args)) code.WriteString("\n__main__(json)\n__keys\n") vm := goja.New() if err := vm.Set("json", input.ToValue(vm)); err != nil { return nil } value, err := vm.RunString(code.String()) if err != nil { return nil } if array, ok := value.Export().([]interface{}); ok { prefix := dropTail(compWord) var reply []Reply for _, key := range array { k := key.(string) reply = append(reply, Reply{ Display: join("", k), Value: join(prefix, k), Type: "key", }) } return reply } return nil } var alphaRe = regexp.MustCompile(`^[A-Za-z_$][A-Za-z0-9_$]*$`) func join(prefix, key string) string { if alphaRe.MatchString(key) { return prefix + "." + key } else { if prefix == "" { return fmt.Sprintf(".[%q]", key) } return fmt.Sprintf("%s[%q]", prefix, key) } } func filterArgs(args []string) []string { filtered := make([]string, 0, len(args)) for _, arg := range args { found := false for _, flag := range Flags { if arg == flag.Value { found = true break } } if !found { filtered = append(filtered, arg) } } return filtered } func fileComplete(compWord string) []Reply { original := compWord // Step 1: Expand ~ to home directory if strings.HasPrefix(compWord, "~") { if compWord == "~" || strings.HasPrefix(compWord, "~/") { home, err := os.UserHomeDir() if err == nil { compWord = filepath.Join(home, compWord[1:]) } } else { // We don't support ~username completion return nil } } // Step 2: If compWord ends in "/", treat it as a directory and add a "*" pattern info, err := os.Stat(compWord) if err == nil && info.IsDir() && !strings.HasSuffix(compWord, "*") { compWord = filepath.Join(compWord, "*") } else if !strings.HasSuffix(compWord, "*") { // Add wildcard if not already present compWord = compWord + "*" } // Step 3: Perform globbing files, err := filepath.Glob(compWord) if err != nil { return nil } // Step 4: Format matches var matches []Reply for _, match := range files { if match == "." || match == ".." { continue } var suggestion string if strings.HasPrefix(original, "~") { home, _ := os.UserHomeDir() if strings.HasPrefix(match, home) { suggestion = "~" + strings.TrimPrefix(match, home) } else { suggestion = match } } else if filepath.IsAbs(original) { suggestion = match } else { rel, err := filepath.Rel(".", match) if err != nil { continue } suggestion = rel } dirSuffix := "" info, err := os.Stat(match) if err == nil { if info.IsDir() { dirSuffix = "/" } } matches = append(matches, Reply{ Display: filepath.Base(suggestion) + dirSuffix, Value: suggestion, Type: "file", }) } return matches } ================================================ FILE: internal/complete/complete.zsh ================================================ #compdef fx _fx() { local -a reply reply=("${(@f)$(COMP_ZSH="${LBUFFER}" fx)}") if (( ${#reply} )); then local -a insert_files display_files insert_other display_other local line display rest value typ for line in "${reply[@]}"; do display="${line%%$'\t'*}" rest="${line#*$'\t'}" value="${rest%%$'\t'*}" typ="${rest#*$'\t'}" if [[ "$typ" == "file" ]]; then display_files+=("$display") insert_files+=("$value") else display_other+=("$display") insert_other+=("$value") fi done if (( ${#insert_files} )); then compadd -f -d display_files -a insert_files fi if (( ${#insert_other} )); then compadd -S '' -d display_other -a insert_other fi fi } if [ "$funcstack[1]" = "_fx" ]; then _fx "$@" else compdef _fx fx fi ================================================ FILE: internal/complete/complete_test.go ================================================ package complete import ( "testing" "github.com/antonmedv/fx/internal/jsonx" ) func TestKeysComplete(t *testing.T) { tests := []struct { name string json string args []string compWord string want []string }{ { name: "simple object keys with empty compWord", json: `{"foo": 1, "bar": 2, "baz": 3}`, args: []string{"fx", "file.json"}, compWord: "", want: []string{".foo", ".bar", ".baz"}, }, { name: "nested object keys with trailing dot", json: `{"outer": {"inner1": 1, "inner2": 2}}`, args: []string{"fx", "file.json", ".outer."}, compWord: ".outer.", want: []string{".outer.inner1", ".outer.inner2"}, }, { name: "nested object without trailing dot returns root keys", json: `{"outer": {"inner1": 1, "inner2": 2}}`, args: []string{"fx", "file.json", ".outer"}, compWord: ".outer", want: []string{".outer"}, }, { name: "nested object with partial compWord", json: `{"data": {"name": "test", "value": 42}}`, args: []string{"fx", "file.json", ".data."}, compWord: ".data.", want: []string{".data.name", ".data.value"}, }, { name: "empty object", json: `{}`, args: []string{"fx", "file.json"}, compWord: "", want: nil, }, { name: "key with special characters", json: `{"normal": 1, "with-dash": 2, "with space": 3}`, args: []string{"fx", "file.json"}, compWord: "", want: []string{".normal", ".[\"with-dash\"]", ".[\"with space\"]"}, }, { name: "deeply nested with trailing dot", json: `{"a": {"b": {"c": {"d": 1}}}}`, args: []string{"fx", "file.json", ".a.b.c."}, compWord: ".a.b.c.", want: []string{".a.b.c.d"}, }, { name: "deeply nested without trailing dot returns parent keys", json: `{"a": {"b": {"c": {"d": 1}}}}`, args: []string{"fx", "file.json", ".a.b.c"}, compWord: ".a.b.c", want: []string{".a.b.c"}, }, { name: "array returns no keys", json: `[1, 2, 3]`, args: []string{"fx", "file.json"}, compWord: "", want: nil, }, { name: "primitive value returns no keys", json: `"hello"`, args: []string{"fx", "file.json"}, compWord: "", want: nil, }, { name: "numeric keys use bracket notation", json: `{"123": "numeric", "abc": "alpha"}`, args: []string{"fx", "file.json"}, compWord: "", want: []string{".[\"123\"]", ".abc"}, }, { name: "key starting with underscore", json: `{"_private": 1, "public": 2}`, args: []string{"fx", "file.json"}, compWord: "", want: []string{"._private", ".public"}, }, { name: "key starting with dollar", json: `{"$ref": "#/defs", "name": "test"}`, args: []string{"fx", "file.json"}, compWord: "", want: []string{".$ref", ".name"}, }, { name: "object inside array via bracket access with trailing dot", json: `{"items": [{"x": 1, "y": 2}]}`, args: []string{"fx", "file.json", ".items[0]."}, compWord: ".items[0].", want: []string{".items[0].x", ".items[0].y"}, }, { name: "multiple args combines path", json: `{"a": {"b": {"c": 1}}}`, args: []string{"fx", "file.json", ".a", ".b."}, compWord: ".b.", want: []string{".b.c"}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { node, err := jsonx.Parse([]byte(tt.json)) if err != nil { t.Fatalf("failed to parse JSON: %v", err) } got := KeysComplete(node, tt.args, tt.compWord) if len(got) != len(tt.want) { t.Errorf("KeysComplete() returned %d replies, want %d", len(got), len(tt.want)) t.Errorf("got: %v", replyValues(got)) t.Errorf("want: %v", tt.want) return } gotValues := replyValues(got) for i, want := range tt.want { if gotValues[i] != want { t.Errorf("KeysComplete()[%d].Value = %q, want %q", i, gotValues[i], want) } } }) } } func replyValues(replies []Reply) []string { values := make([]string, len(replies)) for i, r := range replies { values[i] = r.Value } return values } func TestKeysComplete_Display(t *testing.T) { json := `{"foo": 1, "bar": 2}` node, err := jsonx.Parse([]byte(json)) if err != nil { t.Fatalf("failed to parse JSON: %v", err) } got := KeysComplete(node, []string{"fx", "file.json"}, "") for _, r := range got { if r.Type != "key" { t.Errorf("expected Type to be 'key', got %q", r.Type) } if r.Display == "" { t.Error("expected Display to be non-empty") } } } func TestKeysComplete_DisplayVsValue(t *testing.T) { tests := []struct { name string json string args []string compWord string wantDisplay string wantValue string }{ { name: "display shows key with dot prefix, value shows full path", json: `{"outer": {"inner": 1}}`, args: []string{"fx", "file.json", ".outer."}, compWord: ".outer.", wantDisplay: ".inner", wantValue: ".outer.inner", }, { name: "special key display and value both use bracket notation", json: `{"key-dash": 1}`, args: []string{"fx", "file.json"}, compWord: "", wantDisplay: ".[\"key-dash\"]", wantValue: ".[\"key-dash\"]", }, { name: "regular key at root", json: `{"foo": 1}`, args: []string{"fx", "file.json"}, compWord: "", wantDisplay: ".foo", wantValue: ".foo", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { node, err := jsonx.Parse([]byte(tt.json)) if err != nil { t.Fatalf("failed to parse JSON: %v", err) } got := KeysComplete(node, tt.args, tt.compWord) if len(got) == 0 { t.Fatal("expected at least one reply") } if got[0].Display != tt.wantDisplay { t.Errorf("Display = %q, want %q", got[0].Display, tt.wantDisplay) } if got[0].Value != tt.wantValue { t.Errorf("Value = %q, want %q", got[0].Value, tt.wantValue) } }) } } ================================================ FILE: internal/complete/prelude.js ================================================ const __keys = new Set() Object.prototype.__keys = function () { if (Array.isArray(this)) return if (typeof this === 'string') return if (this instanceof String) return if (this === globalThis) return if (typeof this === 'object' && this !== null) Object.keys(this).forEach(x => __keys.add(x)) } function __autocomplete() { const keys = [] for (const key of Object.keys(globalThis)) { if (key.startsWith('__')) continue keys.push(key) } keys.push( 'JSON.stringify', 'JSON.parse', 'YAML.stringify', 'YAML.parse', 'Object.keys', 'Object.values', 'Object.entries', 'Object.fromEntries', 'Array.isArray', 'Array.from', 'console.log', ) return keys } ================================================ FILE: internal/complete/utils.go ================================================ package complete import ( "fmt" "os" "path/filepath" "strings" ) func compReply(reply []Reply, withDisplay bool) { var lines []string for _, line := range reply { if withDisplay { lines = append(lines, fmt.Sprintf("%s\t%s\t%s", line.Display, line.Value, line.Type)) } else { lines = append(lines, line.Value) } } fmt.Print(strings.Join(lines, "\n")) } func filterReply(reply []Reply, compWord string) []Reply { var filtered []Reply for _, word := range reply { if strings.HasPrefix(word.Value, compWord) { filtered = append(filtered, word) } } return filtered } func isFile(path string) bool { if strings.HasPrefix(path, "~") { home, err := os.UserHomeDir() if err == nil { path = filepath.Join(home, path[1:]) } } info, err := os.Stat(path) if err != nil { return false } return !info.IsDir() } func dropTail(s string) string { parts := strings.Split(s, ".") if len(parts) == 1 { return s } return strings.Join(parts[:len(parts)-1], ".") } func balanceBrackets(code string) string { var stack []rune brackets := map[rune]rune{')': '(', '}': '{', ']': '['} reverseBrackets := map[rune]rune{'(': ')', '{': '}', '[': ']'} for _, char := range code { switch char { case '(', '{', '[': stack = append(stack, char) case ')', '}', ']': if len(stack) > 0 && brackets[char] == stack[len(stack)-1] { stack = stack[:len(stack)-1] // Pop } } } for i := len(stack) - 1; i >= 0; i-- { code += string(reverseBrackets[stack[i]]) } return code } func lastWord(line string) string { words := strings.Split(line, " ") var s string if len(words) > 0 { s = words[len(words)-1] } return s } func writeLog(args ...interface{}) { file, err := os.OpenFile("complete.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) if err != nil { return } _, _ = fmt.Fprintln(file, args...) _ = file.Close() } ================================================ FILE: internal/engine/engine.go ================================================ package engine import ( _ "embed" "io" "reflect" "strconv" "strings" "github.com/dop251/goja" "github.com/antonmedv/fx/internal/jsonx" "github.com/antonmedv/fx/internal/pretty" ) //go:embed stdlib.js var Stdlib string func init() { fxrc, err := readFxrc() if err != nil { panic(err) } Stdlib += fxrc } type Parser interface { Parse() (*jsonx.Node, error) Recover() *jsonx.Node } type Options struct { Slurp bool WithInline bool WriteOut func(string) WriteErr func(string) } func Start(parser Parser, args []string, opts Options) int { if opts.Slurp { var ok bool parser, ok = Slurp(parser, opts.WriteErr) if !ok { return 1 } } isPrettyPrintArg := len(args) == 1 && (args[0] == "." || args[0] == "this" || args[0] == "x") // Fast path. if isPrettyPrintArg { for { node, err := parser.Parse() if err != nil { if err == io.EOF { break } opts.WriteErr(err.Error()) return 1 } if node.Kind == jsonx.String { unquoted, err := strconv.Unquote(node.Value) if err != nil { panic(err) } opts.WriteOut(unquoted) } else { opts.WriteOut(pretty.Print(node, opts.WithInline)) } } return 0 } for i := range args { if err := validateSyntax(args, i); err != nil { jsCode := transpile(args[i]) snippet := formatErr(args, i, jsCode) message := errorToString(err) opts.WriteErr(snippet + message) return 1 } } var code strings.Builder code.WriteString(Stdlib) code.WriteString(JS(args)) vm := NewVM(opts.WriteOut) if _, err := vm.RunString(code.String()); err != nil { opts.WriteErr(errorToString(err)) return 1 } skip := vm.Get("skip") undefined := vm.Get("undefined") main, _ := goja.AssertFunction(vm.Get("__main__")) echo := func(output goja.Value) { rtype := output.ExportType() if output.StrictEquals(undefined) { opts.WriteErr("undefined") } else if rtype != nil && rtype.Kind() == reflect.String { opts.WriteOut(output.String()) } else { jsonOut := Stringify(output, vm, 0) nodeOut, err := jsonx.Parse([]byte(jsonOut)) if err != nil { panic(err) } opts.WriteOut(pretty.Print(nodeOut, opts.WithInline)) } } for { node, err := parser.Parse() if err != nil { if err == io.EOF { break } opts.WriteErr(err.Error()) return 1 } input := node.ToValue(vm) output, exitCode, err := callMain(main, input) if exitCode >= 0 { return exitCode } if err != nil { opts.WriteErr(errorToString(err)) return 1 } if output.StrictEquals(skip) { continue } echo(output) } return 0 } func callMain(main goja.Callable, input goja.Value) (output goja.Value, exitCode int, err error) { exitCode = -1 defer func() { if r := recover(); r != nil { if e, ok := r.(ExitError); ok { exitCode = e.Code } else { panic(r) } } }() output, err = main(goja.Undefined(), input) return } func validateSyntax(args []string, i int) error { var code strings.Builder code.WriteString("\nfunction __main__(json) {\n") code.WriteString(Body(args, i)) code.WriteString(" return json\n}\n") vm := goja.New() _, err := vm.RunString(code.String()) return err } ================================================ FILE: internal/engine/engine_test.go ================================================ package engine_test import ( "strings" "testing" "github.com/stretchr/testify/assert" "github.com/antonmedv/fx/internal/engine" "github.com/antonmedv/fx/internal/jsonx" ) func TestEngine(t *testing.T) { tests := []struct { name string input string args []string expects []string errCount int }{ { name: "fast path: string as raw", input: `"Hello, world!"`, args: []string{"."}, expects: []string{"Hello, world!"}, errCount: 0, }, { name: "string as raw", input: `"Hello, world!"`, args: []string{"x => this"}, expects: []string{"Hello, world!"}, errCount: 0, }, { name: "skip works", input: "1 2 3 4", args: []string{"x % 2 != 0 ? skip : x"}, expects: []string{"2", "4"}, errCount: 0, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { parser := jsonx.NewJsonParser(strings.NewReader(tc.input), false) var outs, errs []string writeOut := func(s string) { outs = append(outs, s) } writeErr := func(s string) { errs = append(errs, s) } opts := engine.Options{ Slurp: false, WithInline: false, WriteOut: writeOut, WriteErr: writeErr, } exitCode := engine.Start(parser, tc.args, opts) assert.Equal(t, 0, exitCode) assert.Len(t, errs, tc.errCount, "%s: unexpected error count", tc.name) assert.Equal(t, tc.expects, outs, "%s: outputs mismatch", tc.name) }) } } func TestStart_InvalidJSON(t *testing.T) { input := `{"unclosed": 1` parser := jsonx.NewJsonParser(strings.NewReader(input), false) var outs, errs []string writeOut := func(s string) { outs = append(outs, s) } writeErr := func(s string) { errs = append(errs, s) } opts := engine.Options{ Slurp: false, WithInline: false, WriteOut: writeOut, WriteErr: writeErr, } exitCode := engine.Start(parser, []string{".unclosed + '!'"}, opts) assert.Equal(t, 1, exitCode) assert.Len(t, errs, 1, "Expected one error message") } func TestStart_FastPath_InvalidJSON(t *testing.T) { input := `{"unclosed": 1` parser := jsonx.NewJsonParser(strings.NewReader(input), false) var outs, errs []string writeOut := func(s string) { outs = append(outs, s) } writeErr := func(s string) { errs = append(errs, s) } opts := engine.Options{ Slurp: false, WithInline: false, WriteOut: writeOut, WriteErr: writeErr, } exitCode := engine.Start(parser, []string{"."}, opts) assert.Equal(t, 1, exitCode) assert.Len(t, errs, 1, "Expected one error message") } func TestStart_EscapeSequences(t *testing.T) { input := `{"emoji": "\ud83d\ude80"}` parser := jsonx.NewJsonParser(strings.NewReader(input), false) var outs, errs []string writeOut := func(s string) { outs = append(outs, s) } writeErr := func(s string) { errs = append(errs, s) } opts := engine.Options{ Slurp: false, WithInline: false, WriteOut: writeOut, WriteErr: writeErr, } exitCode := engine.Start(parser, []string{".emoji"}, opts) assert.Equal(t, 0, exitCode) assert.Len(t, errs, 0, "Expected no error messages") assert.Equal(t, "🚀", outs[0]) } func TestStart_EscapeSequences_in_key(t *testing.T) { input := `{"\ud83d\ude80": "\ud83d\ude80"}` parser := jsonx.NewJsonParser(strings.NewReader(input), false) var outs, errs []string writeOut := func(s string) { outs = append(outs, s) } writeErr := func(s string) { errs = append(errs, s) } opts := engine.Options{ Slurp: false, WithInline: false, WriteOut: writeOut, WriteErr: writeErr, } exitCode := engine.Start(parser, []string{"x => x"}, opts) assert.Equal(t, 0, exitCode) assert.Len(t, errs, 0, "Expected no error messages") } ================================================ FILE: internal/engine/format_err.go ================================================ package engine import ( "fmt" "os" "strings" "github.com/charmbracelet/x/term" "github.com/mattn/go-runewidth" ) func formatErr(args []string, i int, jsCode string) string { width, _, err := term.GetSize(os.Stdout.Fd()) if err != nil || width <= 0 { width = 80 } if i < 0 || i >= len(args) { return fmt.Sprintf("Invalid argument index: %d", i) } code := args[i] const indentCols = 2 // we print " " before everything const sepCols = 1 // the single space between pre and code reserve := indentCols + sepCols + runewidth.StringWidth(code) + sepCols available := width - reserve if available < 0 { available = 0 } maxCtx := available / 2 pre := strings.Join(args[:i], " ") post := strings.Join(args[i+1:], " ") pre = trimLeft(pre, maxCtx) post = trimRight(post, maxCtx) leftSep := 0 if pre != "" { leftSep = sepCols } spacerCols := indentCols + runewidth.StringWidth(pre) + leftSep spacer := strings.Repeat(" ", spacerCols) var sb strings.Builder sb.WriteString("\n") sb.WriteString(strings.Repeat(" ", indentCols)) if pre != "" { sb.WriteString(pre) sb.WriteByte(' ') } sb.WriteString(code) if post != "" { sb.WriteByte(' ') sb.WriteString(post) } sb.WriteByte('\n') sb.WriteString(spacer) sb.WriteString(strings.Repeat("^", runewidth.StringWidth(code))) sb.WriteByte('\n') if jsCode != "" && jsCode != code { snippet := jsCode if runewidth.StringWidth(snippet) > width { snippet = trimRight(snippet, width) } sb.WriteByte('\n') sb.WriteString(snippet) sb.WriteByte('\n') } sb.WriteString("\n") return sb.String() } func trimLeft(s string, ctx int) string { if runewidth.StringWidth(s) <= ctx { return s } rs := []rune(s) widthAccum := 0 var out []rune for i := len(rs) - 1; i >= 0; i-- { w := runewidth.RuneWidth(rs[i]) if widthAccum+w > ctx-1 { break } widthAccum += w out = append([]rune{rs[i]}, out...) } return "…" + string(out) } func trimRight(s string, ctx int) string { if runewidth.StringWidth(s) <= ctx { return s } rs := []rune(s) widthAccum := 0 var out []rune for _, r := range rs { w := runewidth.RuneWidth(r) if widthAccum+w > ctx-1 { break } out = append(out, r) widthAccum += w } return string(out) + "…" } ================================================ FILE: internal/engine/fxrc.go ================================================ package engine import ( "fmt" "os" "path/filepath" "strings" ) func readFxrc() (string, error) { var builder strings.Builder // Determine search paths cwd, err := os.Getwd() if err != nil { return "", fmt.Errorf("get cwd: %w", err) } paths := []string{filepath.Join(cwd, ".fxrc.js")} home, err := os.UserHomeDir() if err != nil { return "", fmt.Errorf("get home: %w", err) } paths = append(paths, filepath.Join(home, ".fxrc.js")) xdgHome := os.Getenv("XDG_CONFIG_HOME") if xdgHome == "" { xdgHome = filepath.Join(home, ".config") } paths = append(paths, filepath.Join(xdgHome, "fx", ".fxrc.js")) xdgDirs := os.Getenv("XDG_CONFIG_DIRS") if xdgDirs == "" { xdgDirs = "/etc/xdg" } for _, dir := range strings.Split(xdgDirs, ":") { paths = append(paths, filepath.Join(dir, "fx", ".fxrc.js")) } // Read and combine for _, path := range uniq(paths) { info, err := os.Stat(path) if err != nil || info.IsDir() { continue // skip missing or directories } data, err := os.ReadFile(path) if err != nil { return "", fmt.Errorf("read %s: %w", path, err) } builder.Write(data) builder.WriteString("\n") } return builder.String(), nil } func uniq(paths []string) []string { seen := make(map[string]bool) result := []string{} for _, path := range paths { if !seen[path] { seen[path] = true result = append(result, path) } } return result } ================================================ FILE: internal/engine/quote.go ================================================ package engine import ( "fmt" "strings" "unicode/utf8" ) func Quote(s string) string { var err error var b strings.Builder b.WriteByte('"') for i := 0; i < len(s); { r, width := utf8.DecodeRuneInString(s[i:]) switch r { case '"': b.WriteString(`\"`) case '\\': b.WriteString(`\\`) case '\b': b.WriteString(`\b`) case '\f': b.WriteString(`\f`) case '\n': b.WriteString(`\n`) case '\r': b.WriteString(`\r`) case '\t': b.WriteString(`\t`) default: if r < 0x20 || r == 0x7F { // Control characters must be escaped as \uXXXX _, err = fmt.Fprintf(&b, `\u%04x`, r) if err != nil { panic(err) } } else if r == utf8.RuneError && width == 1 { // Invalid UTF-8 sequence - escape the byte _, err = fmt.Fprintf(&b, `\u%04x`, s[i]) if err != nil { panic(err) } } else { // Regular character - write as-is b.WriteRune(r) } } i += width } b.WriteByte('"') return b.String() } ================================================ FILE: internal/engine/quote_test.go ================================================ package engine_test import ( "encoding/json" "testing" "github.com/stretchr/testify/assert" "github.com/antonmedv/fx/internal/engine" ) func TestQuote_BasicASCII(t *testing.T) { assert.Equal(t, "\"hello\"", engine.Quote("hello")) assert.Equal(t, "\"\"", engine.Quote("")) assert.Equal(t, "\"Hello, world!\"", engine.Quote("Hello, world!")) } func TestQuote_EscapesSpecialCharacters(t *testing.T) { assert.Equal(t, `"\""`, engine.Quote("\"")) assert.Equal(t, `"\\"`, engine.Quote("\\")) assert.Equal(t, `"\b"`, engine.Quote("\b")) assert.Equal(t, `"\f"`, engine.Quote("\f")) assert.Equal(t, `"\n"`, engine.Quote("\n")) assert.Equal(t, `"\r"`, engine.Quote("\r")) assert.Equal(t, `"\t"`, engine.Quote("\t")) } func TestQuote_ControlCharactersAndDEL(t *testing.T) { hex4Lower := func(n int) string { const hexdigits = "0123456789abcdef" b0 := hexdigits[(n>>12)&0xF] b1 := hexdigits[(n>>8)&0xF] b2 := hexdigits[(n>>4)&0xF] b3 := hexdigits[n&0xF] return string([]byte{b0, b1, b2, b3}) } // 0x00 .. 0x1F should be \uXXXX for b := 0; b < 0x20; b++ { s := string([]byte{byte(b)}) q := engine.Quote(s) expected := "\"\\u" + hex4Lower(b) + "\"" // For those with dedicated escapes, engine.Quote uses short escapes; both are valid. // We'll accept either short escape or \uXXXX for those particular bytes. switch b { case '\b': assert.Equal(t, `"\b"`, q) case '\f': assert.Equal(t, `"\f"`, q) case '\n': assert.Equal(t, `"\n"`, q) case '\r': assert.Equal(t, `"\r"`, q) case '\t': assert.Equal(t, `"\t"`, q) default: assert.Equal(t, expected, q, "byte %d", b) } } // 0x7F DEL assert.Equal(t, `"\u007f"`, engine.Quote(string([]byte{0x7F}))) } func TestQuote_BMP_Characters_AsIs(t *testing.T) { // Latin-1 supplement, Cyrillic, CJK BMP characters should appear as-is assert.Equal(t, "\"café\"", engine.Quote("café")) assert.Equal(t, "\"Привет\"", engine.Quote("Привет")) assert.Equal(t, "\"漢字\"", engine.Quote("漢字")) } func TestQuote_SurrogatePairs_AsIs(t *testing.T) { assert.Equal(t, `"🚀"`, engine.Quote("🚀")) assert.Equal(t, `"👍🏻"`, engine.Quote("👍🏻")) assert.Equal(t, `"𝄞"`, engine.Quote("𝄞")) } func TestQuote_InvalidUTF8BytesAreEscaped(t *testing.T) { // Construct a string with invalid UTF-8 byte 0xFF and 0xC0 (overlong lead) s := string([]byte{'A', 0xFF, 'B', 0xC0, 'C'}) got := engine.Quote(s) // Expect bytes to be escaped as \u00xx in lowercase hex want := `"A\u00ffB\u00c0C"` assert.Equal(t, want, got) } func TestQuote_JSONRoundTrip_ValidUTF8(t *testing.T) { tests := []struct{ input string }{ {""}, {"simple"}, {"line\nfeed"}, {"tab\tchar"}, {"quote \" here"}, {"backslash \\"}, {"café"}, {"Привет"}, {"漢字"}, {"emoji 🚀"}, {"mix: \b\f\n\r\t and \u007F:" + string([]byte{0x7F})}, {"Line1\n\t\"Quote\" and backslash \\ and DEL:" + string([]byte{0x7F}) + " and emoji 🚀"}, } for _, tt := range tests { t.Run(tt.input, func(t *testing.T) { q := engine.Quote(tt.input) var v string err := json.Unmarshal([]byte(q), &v) assert.NoError(t, err, "failed to unmarshal: %q", q) assert.Equal(t, tt.input, v) }) } } ================================================ FILE: internal/engine/slurp.go ================================================ package engine import ( "io" "github.com/antonmedv/fx/internal/jsonx" ) func Slurp(parser Parser, writeErr func(string)) (Parser, bool) { arr := &jsonx.Node{ Kind: jsonx.Array, Value: "[", LineNumber: 1, } end := arr for { node, err := parser.Parse() if err != nil { if err == io.EOF { break } writeErr(err.Error()) return nil, false } node.Parent = arr it := node for it != nil { it.Depth++ it.LineNumber++ it = it.Next } end.Next = node end = node.Bottom() end.Comma = true } end.Comma = false end.Next = &jsonx.Node{ Kind: jsonx.Array, LineNumber: end.LineNumber + 1, Value: "]", } arr.End = end.Next return &slurpParser{node: arr}, true } type slurpParser struct { node *jsonx.Node } func (p *slurpParser) Parse() (*jsonx.Node, error) { if p.node == nil { return nil, io.EOF } node := p.node p.node = nil return node, nil } func (p *slurpParser) Recover() *jsonx.Node { return nil } ================================================ FILE: internal/engine/stdlib.js ================================================ 'use strict' const console = { log: function (...args) { const parts = [] for (const arg of args) { if (typeof arg === 'undefined') { parts.push('undefined') } else if (typeof arg === 'string') { parts.push(arg) } else { parts.push(JSON.stringify(arg, null, 2)) } } println(parts.join(' ')) }, } const skip = Symbol('skip') function apply(fn, ...args) { if (typeof fn === 'function') return fn(...args) return fn } function len(x) { if (Array.isArray(x)) return x.length if (typeof x === 'string') return x.length if (typeof x === 'object' && x !== null) return Object.keys(x).length throw new Error(`Cannot get length of ${typeof x}`) } function uniq(x) { if (Array.isArray(x)) return [...new Set(x)] throw new Error(`Cannot get unique values of ${typeof x}`) } function sort(x) { if (Array.isArray(x)) return x.sort() throw new Error(`Cannot sort ${typeof x}`) } function isFalsely(x) { return x === false || x === null || x === undefined } function filter(fn) { return function (x) { if (Array.isArray(x)) { return x.filter((v, i) => !isFalsely(fn(v, i))) } return isFalsely(fn(x)) ? skip : x } } function map(fn) { return function (x) { if (Array.isArray(x)) { return x.map((v, i) => fn(v, i)) } return fn(x) } } function walk(fn) { return function recurse(value, key = null) { if (Array.isArray(value)) { const mapped = value.map((v, i) => recurse(v, i)) return fn(mapped, key) } else if (value !== null && typeof value === 'object') { const result = {} for (const [k, v] of Object.entries(value)) { result[k] = recurse(v, k) } return fn(result, key) } else { return fn(value, key) } } } function sortBy(fn) { return function (x) { if (Array.isArray(x)) return x.sort((a, b) => { const fa = fn(a) const fb = fn(b) return fa < fb ? -1 : fa > fb ? 1 : 0 }) throw new Error(`Cannot sort ${typeof x}`) } } function sortKeys(x) { if (Array.isArray(x)) { return x.map(sortKeys) } if (typeof x === 'object' && x !== null) { const sorted = {} for (const key of Object.keys(x).sort()) { sorted[key] = sortKeys(x[key]) } return sorted } return x } function groupBy(keyFn) { return function (x) { const grouped = {} for (const item of x) { const key = typeof keyFn === 'function' ? keyFn(item) : item[keyFn] if (!Object.prototype.hasOwnProperty.call(grouped, key)) grouped[key] = [] grouped[key].push(item) } return grouped } } function chunk(size) { return function (x) { const res = [] let i = 0 while (i < x.length) { res.push(x.slice(i, i += size)) } return res } } function zip(...x) { const length = Math.min(...x.map(a => a.length)) const res = [] for (let i = 0; i < length; i++) { res.push(x.map(a => a[i])) } return res } function flatten(x) { if (Array.isArray(x)) return x.flat() throw new Error(`Cannot flatten ${typeof x}`) } function reverse(x) { if (Array.isArray(x)) return x.reverse() throw new Error(`Cannot reverse ${typeof x}`) } function keys(x) { if (typeof x === 'object' && x !== null) return Object.keys(x) throw new Error(`Cannot get keys of ${typeof x}`) } function values(x) { if (typeof x === 'object' && x !== null) return Object.values(x) throw new Error(`Cannot get values of ${typeof x}`) } function list(x) { if (Array.isArray(x)) { for (const y of x) console.log(y) return skip } throw new Error(`Cannot list ${typeof x}`) } function del(key) { return function (x) { if (Array.isArray(x)) { const copy = [...x] copy.splice(key, 1) return copy } if (typeof x === 'object' && x !== null) { const copy = {...x} delete copy[key] return copy } throw new Error(`Cannot delete key from ${typeof x}`) } } function exit(code) { __exit__(code) } function save(x) { if (typeof x === 'undefined') throw new Error('Cannot save undefined') __save__(__stringify__(x, null, 2)) return x } function toBase64(x) { return __toBase64__(x) } function fromBase64(x) { return __fromBase64__(x) } const YAML = { stringify: x => __yaml_stringify__(x), parse: x => JSON.parse(__yaml_parse__(x)), } ================================================ FILE: internal/engine/stdlib_test.go ================================================ package engine_test import ( "strings" "testing" "github.com/dop251/goja" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/antonmedv/fx/internal/engine" ) // setupVM creates a new goja runtime with stdlib loaded. func setupVM(t *testing.T) *goja.Runtime { var output []string vm := engine.NewVM(func(s string) { output = append(output, s) }) _, err := vm.RunString(engine.Stdlib) require.NoError(t, err, "Failed to load stdlib") return vm } // setupVMWithOutput creates a new goja runtime with stdlib loaded and returns output slice. func setupVMWithOutput(t *testing.T) (*goja.Runtime, *[]string) { output := &[]string{} vm := engine.NewVM(func(s string) { *output = append(*output, s) }) _, err := vm.RunString(engine.Stdlib) require.NoError(t, err, "Failed to load stdlib") return vm, output } // TestApply tests the apply function. func TestApply(t *testing.T) { vm := setupVM(t) tests := []struct { name string code string expected interface{} }{ {"apply function", "apply(x => x * 2, 5)", int64(10)}, {"apply non-function", "apply(42)", int64(42)}, {"apply with multiple args", "apply((a, b) => a + b, 2, 3)", int64(5)}, {"apply string", "apply('hello')", "hello"}, {"apply null", "apply(null)", nil}, {"apply undefined", "apply(undefined)", goja.Undefined()}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { result, err := vm.RunString(tc.code) require.NoError(t, err) if tc.expected == goja.Undefined() { assert.True(t, goja.IsUndefined(result)) } else { assert.Equal(t, tc.expected, result.Export()) } }) } } // TestLen tests the len function. func TestLen(t *testing.T) { vm := setupVM(t) tests := []struct { name string code string expected interface{} hasError bool }{ {"array length", "len([1, 2, 3])", int64(3), false}, {"empty array", "len([])", int64(0), false}, {"string length", "len('hello')", int64(5), false}, {"empty string", "len('')", int64(0), false}, {"object keys count", "len({a: 1, b: 2})", int64(2), false}, {"empty object", "len({})", int64(0), false}, {"number error", "len(42)", nil, true}, {"null error", "len(null)", nil, true}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { result, err := vm.RunString(tc.code) if tc.hasError { assert.Error(t, err) } else { require.NoError(t, err) assert.Equal(t, tc.expected, result.Export()) } }) } } // TestUniq tests the uniq function. func TestUniq(t *testing.T) { vm := setupVM(t) tests := []struct { name string code string expected interface{} hasError bool }{ {"unique numbers", "uniq([1, 2, 2, 3, 3, 3])", []interface{}{int64(1), int64(2), int64(3)}, false}, {"unique strings", "uniq(['a', 'b', 'a', 'c'])", []interface{}{"a", "b", "c"}, false}, {"already unique", "uniq([1, 2, 3])", []interface{}{int64(1), int64(2), int64(3)}, false}, {"empty array", "uniq([])", []interface{}{}, false}, {"non-array error", "uniq('hello')", nil, true}, {"number error", "uniq(42)", nil, true}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { result, err := vm.RunString(tc.code) if tc.hasError { assert.Error(t, err) } else { require.NoError(t, err) assert.Equal(t, tc.expected, result.Export()) } }) } } // TestSort tests the sort function. func TestSort(t *testing.T) { vm := setupVM(t) tests := []struct { name string code string expected interface{} hasError bool }{ {"sort numbers", "sort([3, 1, 2])", []interface{}{int64(1), int64(2), int64(3)}, false}, {"sort strings", "sort(['c', 'a', 'b'])", []interface{}{"a", "b", "c"}, false}, {"empty array", "sort([])", []interface{}{}, false}, {"already sorted", "sort([1, 2, 3])", []interface{}{int64(1), int64(2), int64(3)}, false}, {"non-array error", "sort('hello')", nil, true}, {"number error", "sort(42)", nil, true}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { result, err := vm.RunString(tc.code) if tc.hasError { assert.Error(t, err) } else { require.NoError(t, err) assert.Equal(t, tc.expected, result.Export()) } }) } } // TestFilter tests the filter function. func TestFilter(t *testing.T) { vm := setupVM(t) tests := []struct { name string code string expected interface{} }{ {"filter even", "filter(x => x % 2 === 0)([1, 2, 3, 4])", []interface{}{int64(2), int64(4)}}, {"filter with index", "filter((x, i) => i > 0)(['a', 'b', 'c'])", []interface{}{"b", "c"}}, {"filter all true", "filter(x => true)([1, 2, 3])", []interface{}{int64(1), int64(2), int64(3)}}, {"filter all false", "filter(x => false)([1, 2, 3])", []interface{}{}}, {"filter empty", "filter(x => true)([])", []interface{}{}}, {"filter non-array truthy", "filter(x => x > 0)(5)", int64(5)}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { result, err := vm.RunString(tc.code) require.NoError(t, err) assert.Equal(t, tc.expected, result.Export()) }) } // Test skip symbol for non-array falsy t.Run("filter non-array falsy returns skip", func(t *testing.T) { result, err := vm.RunString("filter(x => x > 10)(5) === skip") require.NoError(t, err) assert.Equal(t, true, result.Export()) }) // Test null/undefined/false filtering t.Run("filter removes null", func(t *testing.T) { result, err := vm.RunString("filter(x => null)([1, 2, 3])") require.NoError(t, err) assert.Equal(t, []interface{}{}, result.Export()) }) t.Run("filter removes undefined", func(t *testing.T) { result, err := vm.RunString("filter(x => undefined)([1, 2, 3])") require.NoError(t, err) assert.Equal(t, []interface{}{}, result.Export()) }) } // TestMap tests the map function. func TestMap(t *testing.T) { vm := setupVM(t) tests := []struct { name string code string expected interface{} }{ {"map double", "map(x => x * 2)([1, 2, 3])", []interface{}{int64(2), int64(4), int64(6)}}, {"map with index", "map((x, i) => i)(['a', 'b', 'c'])", []interface{}{int64(0), int64(1), int64(2)}}, {"map empty", "map(x => x * 2)([])", []interface{}{}}, {"map non-array", "map(x => x * 2)(5)", int64(10)}, {"map to string", "map(x => String(x))([1, 2, 3])", []interface{}{"1", "2", "3"}}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { result, err := vm.RunString(tc.code) require.NoError(t, err) assert.Equal(t, tc.expected, result.Export()) }) } } // TestWalk tests the walk function. func TestWalk(t *testing.T) { vm := setupVM(t) tests := []struct { name string code string expected interface{} }{ {"walk multiply primitives", "walk(x => typeof x === 'number' ? x * 2 : x)({a: 1, b: 2})", map[string]interface{}{"a": int64(2), "b": int64(4)}}, {"walk nested array", "walk(x => typeof x === 'number' ? x + 1 : x)([[1, 2], [3, 4]])", []interface{}{[]interface{}{int64(2), int64(3)}, []interface{}{int64(4), int64(5)}}}, {"walk primitive", "walk(x => x * 2)(5)", int64(10)}, {"walk with key", "walk((v, k) => k === 'double' ? v * 2 : v)({double: 5, keep: 10})", map[string]interface{}{"double": int64(10), "keep": int64(10)}}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { result, err := vm.RunString(tc.code) require.NoError(t, err) assert.Equal(t, tc.expected, result.Export()) }) } } // TestSortBy tests the sortBy function. func TestSortBy(t *testing.T) { vm := setupVM(t) tests := []struct { name string code string expected interface{} hasError bool }{ {"sortBy property", "sortBy(x => x.age)([{name: 'b', age: 30}, {name: 'a', age: 20}])", []interface{}{ map[string]interface{}{"name": "a", "age": int64(20)}, map[string]interface{}{"name": "b", "age": int64(30)}, }, false}, {"sortBy computed", "sortBy(x => -x)([1, 3, 2])", []interface{}{int64(3), int64(2), int64(1)}, false}, {"sortBy string length", "sortBy(x => x.length)(['aaa', 'a', 'aa'])", []interface{}{"a", "aa", "aaa"}, false}, {"sortBy non-array error", "sortBy(x => x)('hello')", nil, true}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { result, err := vm.RunString(tc.code) if tc.hasError { assert.Error(t, err) } else { require.NoError(t, err) assert.Equal(t, tc.expected, result.Export()) } }) } } // TestSortKeys tests the sortKeys function. func TestSortKeys(t *testing.T) { vm := setupVM(t) tests := []struct { name string code string check func(t *testing.T, result goja.Value) }{ { name: "sort object keys", code: "JSON.stringify(sortKeys({c: 1, a: 2, b: 3}))", check: func(t *testing.T, result goja.Value) { assert.Equal(t, `{"a":2,"b":3,"c":1}`, result.Export()) }, }, { name: "sort nested object keys", code: "JSON.stringify(sortKeys({z: {c: 1, a: 2}, y: 3}))", check: func(t *testing.T, result goja.Value) { assert.Equal(t, `{"y":3,"z":{"a":2,"c":1}}`, result.Export()) }, }, { name: "sort array of objects", code: "JSON.stringify(sortKeys([{b: 1, a: 2}, {d: 3, c: 4}]))", check: func(t *testing.T, result goja.Value) { assert.Equal(t, `[{"a":2,"b":1},{"c":4,"d":3}]`, result.Export()) }, }, { name: "primitives unchanged", code: "sortKeys(42)", check: func(t *testing.T, result goja.Value) { assert.Equal(t, int64(42), result.Export()) }, }, { name: "null unchanged", code: "sortKeys(null)", check: func(t *testing.T, result goja.Value) { assert.Nil(t, result.Export()) }, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { result, err := vm.RunString(tc.code) require.NoError(t, err) tc.check(t, result) }) } } // TestGroupBy tests the groupBy function. func TestGroupBy(t *testing.T) { vm := setupVM(t) tests := []struct { name string code string expected interface{} }{ {"groupBy function", "groupBy(x => x % 2 === 0 ? 'even' : 'odd')([1, 2, 3, 4])", map[string]interface{}{ "odd": []interface{}{int64(1), int64(3)}, "even": []interface{}{int64(2), int64(4)}, }}, {"groupBy property name", "groupBy('type')([{type: 'a', v: 1}, {type: 'b', v: 2}, {type: 'a', v: 3}])", map[string]interface{}{ "a": []interface{}{ map[string]interface{}{"type": "a", "v": int64(1)}, map[string]interface{}{"type": "a", "v": int64(3)}, }, "b": []interface{}{ map[string]interface{}{"type": "b", "v": int64(2)}, }, }}, {"groupBy empty", "groupBy(x => x)([])", map[string]interface{}{}}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { result, err := vm.RunString(tc.code) require.NoError(t, err) assert.Equal(t, tc.expected, result.Export()) }) } } // TestChunk tests the chunk function. func TestChunk(t *testing.T) { vm := setupVM(t) tests := []struct { name string code string expected interface{} }{ {"chunk by 2", "chunk(2)([1, 2, 3, 4, 5])", []interface{}{ []interface{}{int64(1), int64(2)}, []interface{}{int64(3), int64(4)}, []interface{}{int64(5)}, }}, {"chunk by 3", "chunk(3)([1, 2, 3, 4, 5, 6])", []interface{}{ []interface{}{int64(1), int64(2), int64(3)}, []interface{}{int64(4), int64(5), int64(6)}, }}, {"chunk empty", "chunk(2)([])", []interface{}{}}, {"chunk by 1", "chunk(1)([1, 2, 3])", []interface{}{ []interface{}{int64(1)}, []interface{}{int64(2)}, []interface{}{int64(3)}, }}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { result, err := vm.RunString(tc.code) require.NoError(t, err) assert.Equal(t, tc.expected, result.Export()) }) } } // TestZip tests the zip function. func TestZip(t *testing.T) { vm := setupVM(t) tests := []struct { name string code string expected interface{} }{ {"zip two arrays", "zip([1, 2, 3], ['a', 'b', 'c'])", []interface{}{ []interface{}{int64(1), "a"}, []interface{}{int64(2), "b"}, []interface{}{int64(3), "c"}, }}, {"zip three arrays", "zip([1, 2], ['a', 'b'], [true, false])", []interface{}{ []interface{}{int64(1), "a", true}, []interface{}{int64(2), "b", false}, }}, {"zip different lengths", "zip([1, 2, 3], ['a', 'b'])", []interface{}{ []interface{}{int64(1), "a"}, []interface{}{int64(2), "b"}, }}, {"zip empty", "zip([], [])", []interface{}{}}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { result, err := vm.RunString(tc.code) require.NoError(t, err) assert.Equal(t, tc.expected, result.Export()) }) } } // TestFlatten tests the flatten function. func TestFlatten(t *testing.T) { vm := setupVM(t) tests := []struct { name string code string expected interface{} hasError bool }{ {"flatten nested", "flatten([[1, 2], [3, 4]])", []interface{}{int64(1), int64(2), int64(3), int64(4)}, false}, {"flatten mixed", "flatten([[1], 2, [3, 4]])", []interface{}{int64(1), int64(2), int64(3), int64(4)}, false}, {"flatten one level", "flatten([[[1, 2]], [[3, 4]]])", []interface{}{ []interface{}{int64(1), int64(2)}, []interface{}{int64(3), int64(4)}, }, false}, {"flatten empty", "flatten([])", []interface{}{}, false}, {"flatten non-array error", "flatten('hello')", nil, true}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { result, err := vm.RunString(tc.code) if tc.hasError { assert.Error(t, err) } else { require.NoError(t, err) assert.Equal(t, tc.expected, result.Export()) } }) } } // TestReverse tests the reverse function. func TestReverse(t *testing.T) { vm := setupVM(t) tests := []struct { name string code string expected interface{} hasError bool }{ {"reverse numbers", "reverse([1, 2, 3])", []interface{}{int64(3), int64(2), int64(1)}, false}, {"reverse strings", "reverse(['a', 'b', 'c'])", []interface{}{"c", "b", "a"}, false}, {"reverse empty", "reverse([])", []interface{}{}, false}, {"reverse single", "reverse([1])", []interface{}{int64(1)}, false}, {"reverse non-array error", "reverse('hello')", nil, true}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { result, err := vm.RunString(tc.code) if tc.hasError { assert.Error(t, err) } else { require.NoError(t, err) assert.Equal(t, tc.expected, result.Export()) } }) } } // TestKeys tests the keys function. func TestKeys(t *testing.T) { vm := setupVM(t) tests := []struct { name string code string hasError bool }{ {"object keys", "keys({a: 1, b: 2}).sort()", false}, {"empty object", "keys({})", false}, {"array keys", "keys([10, 20, 30])", false}, {"null error", "keys(null)", true}, {"number error", "keys(42)", true}, {"string error", "keys('hello')", true}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { result, err := vm.RunString(tc.code) if tc.hasError { assert.Error(t, err) } else { require.NoError(t, err) assert.NotNil(t, result.Export()) } }) } // Specific value checks t.Run("object keys values", func(t *testing.T) { result, err := vm.RunString("keys({a: 1, b: 2}).sort()") require.NoError(t, err) assert.Equal(t, []interface{}{"a", "b"}, result.Export()) }) t.Run("array keys values", func(t *testing.T) { result, err := vm.RunString("keys([10, 20])") require.NoError(t, err) assert.Equal(t, []interface{}{"0", "1"}, result.Export()) }) } // TestValues tests the values function. func TestValues(t *testing.T) { vm := setupVM(t) tests := []struct { name string code string hasError bool }{ {"object values", "values({a: 1, b: 2}).sort()", false}, {"empty object", "values({})", false}, {"array values", "values([10, 20, 30])", false}, {"null error", "values(null)", true}, {"number error", "values(42)", true}, {"string error", "values('hello')", true}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { result, err := vm.RunString(tc.code) if tc.hasError { assert.Error(t, err) } else { require.NoError(t, err) assert.NotNil(t, result.Export()) } }) } // Specific value checks t.Run("object values sorted", func(t *testing.T) { result, err := vm.RunString("values({a: 1, b: 2}).sort()") require.NoError(t, err) assert.Equal(t, []interface{}{int64(1), int64(2)}, result.Export()) }) t.Run("array values", func(t *testing.T) { result, err := vm.RunString("values([10, 20, 30])") require.NoError(t, err) assert.Equal(t, []interface{}{int64(10), int64(20), int64(30)}, result.Export()) }) } // TestList tests the list function. func TestList(t *testing.T) { vm, output := setupVMWithOutput(t) t.Run("list prints each item", func(t *testing.T) { *output = []string{} // Reset output result, err := vm.RunString("list([1, 2, 3]) === skip") require.NoError(t, err) assert.True(t, result.Export().(bool)) assert.Equal(t, []string{"1", "2", "3"}, *output) }) t.Run("list with objects", func(t *testing.T) { *output = []string{} result, err := vm.RunString(`list([{a: 1}]) === skip`) require.NoError(t, err) assert.True(t, result.Export().(bool)) assert.Len(t, *output, 1) }) t.Run("list non-array error", func(t *testing.T) { _, err := vm.RunString("list('hello')") assert.Error(t, err) }) } // TestDel tests the del function. func TestDel(t *testing.T) { vm := setupVM(t) tests := []struct { name string code string expected interface{} hasError bool }{ {"del from object", "del('a')({a: 1, b: 2})", map[string]interface{}{"b": int64(2)}, false}, {"del non-existent key", "del('c')({a: 1, b: 2})", map[string]interface{}{"a": int64(1), "b": int64(2)}, false}, {"del from array", "del(1)([1, 2, 3])", []interface{}{int64(1), int64(3)}, false}, {"del first from array", "del(0)([1, 2, 3])", []interface{}{int64(2), int64(3)}, false}, {"del last from array", "del(2)([1, 2, 3])", []interface{}{int64(1), int64(2)}, false}, {"del from empty array", "del(0)([])", []interface{}{}, false}, {"del from primitive error", "del('a')(42)", nil, true}, {"del from null error", "del('a')(null)", nil, true}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { result, err := vm.RunString(tc.code) if tc.hasError { assert.Error(t, err) } else { require.NoError(t, err) assert.Equal(t, tc.expected, result.Export()) } }) } } // TestSkipSymbol tests the skip symbol. func TestSkipSymbol(t *testing.T) { vm := setupVM(t) t.Run("skip is a symbol", func(t *testing.T) { result, err := vm.RunString("typeof skip") require.NoError(t, err) assert.Equal(t, "symbol", result.Export()) }) t.Run("skip is unique", func(t *testing.T) { result, err := vm.RunString("skip === skip") require.NoError(t, err) assert.True(t, result.Export().(bool)) }) } // TestConsoleLog tests console.log. func TestConsoleLog(t *testing.T) { vm, output := setupVMWithOutput(t) tests := []struct { name string code string expected []string }{ {"log string", "console.log('hello')", []string{"hello"}}, {"log number", "console.log(42)", []string{"42"}}, {"log object", "console.log({a: 1})", []string{"{\n \"a\": 1\n}"}}, {"log undefined", "console.log(undefined)", []string{"undefined"}}, {"log multiple args", "console.log('a', 'b')", []string{"a b"}}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { *output = []string{} _, err := vm.RunString(tc.code) require.NoError(t, err) assert.Equal(t, tc.expected, *output) }) } } // TestToBase64 tests the toBase64 function. func TestToBase64(t *testing.T) { vm := setupVM(t) tests := []struct { name string input string expected string }{ {"encode hello", "toBase64('hello')", "aGVsbG8="}, {"encode empty", "toBase64('')", ""}, {"encode unicode", "toBase64('こんにちは')", "44GT44KT44Gr44Gh44Gv"}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { result, err := vm.RunString(tc.input) require.NoError(t, err) assert.Equal(t, tc.expected, result.Export()) }) } } // TestFromBase64 tests the fromBase64 function. func TestFromBase64(t *testing.T) { vm := setupVM(t) tests := []struct { name string input string expected string hasError bool }{ {"decode hello", "fromBase64('aGVsbG8=')", "hello", false}, {"decode empty", "fromBase64('')", "", false}, {"decode unicode", "fromBase64('44GT44KT44Gr44Gh44Gv')", "こんにちは", false}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { result, err := vm.RunString(tc.input) if tc.hasError { assert.Error(t, err) } else { require.NoError(t, err) assert.Equal(t, tc.expected, result.Export()) } }) } t.Run("invalid base64 error", func(t *testing.T) { _, err := vm.RunString("fromBase64('not-valid-base64!!!')") assert.Error(t, err) }) } // TestYAML tests YAML.parse and YAML.stringify. func TestYAML(t *testing.T) { vm := setupVM(t) t.Run("YAML.parse simple", func(t *testing.T) { result, err := vm.RunString(`YAML.parse('name: John\nage: 30')`) require.NoError(t, err) expected := map[string]interface{}{"name": "John", "age": int64(30)} assert.Equal(t, expected, result.Export()) }) t.Run("YAML.parse array", func(t *testing.T) { result, err := vm.RunString(`YAML.parse('- 1\n- 2\n- 3')`) require.NoError(t, err) expected := []interface{}{int64(1), int64(2), int64(3)} assert.Equal(t, expected, result.Export()) }) t.Run("YAML.stringify simple", func(t *testing.T) { result, err := vm.RunString(`YAML.stringify({name: 'John', age: 30})`) require.NoError(t, err) yamlStr := result.Export().(string) assert.Contains(t, yamlStr, "name: John") assert.Contains(t, yamlStr, "age: 30") }) t.Run("YAML.stringify array", func(t *testing.T) { result, err := vm.RunString(`YAML.stringify([1, 2, 3])`) require.NoError(t, err) yamlStr := result.Export().(string) assert.Contains(t, yamlStr, "- 1") assert.Contains(t, yamlStr, "- 2") assert.Contains(t, yamlStr, "- 3") }) t.Run("YAML.parse invalid", func(t *testing.T) { _, err := vm.RunString(`YAML.parse('invalid: [unclosed')`) assert.Error(t, err) }) } // TestIsFalsely tests the internal isFalsely function behavior. func TestIsFalsely(t *testing.T) { vm := setupVM(t) // isFalsely is used internally by filter // Testing through filter behavior tests := []struct { name string code string expected interface{} }{ {"false is falsely", "filter(x => false)([1])", []interface{}{}}, {"null is falsely", "filter(x => null)([1])", []interface{}{}}, {"undefined is falsely", "filter(x => undefined)([1])", []interface{}{}}, {"0 is not falsely", "filter(x => 0)([1])", []interface{}{int64(1)}}, {"empty string is not falsely", "filter(x => '')([1])", []interface{}{int64(1)}}, {"true is not falsely", "filter(x => true)([1])", []interface{}{int64(1)}}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { result, err := vm.RunString(tc.code) require.NoError(t, err) assert.Equal(t, tc.expected, result.Export()) }) } } // TestChainedOperations tests chaining multiple stdlib functions. func TestChainedOperations(t *testing.T) { vm := setupVM(t) tests := []struct { name string code string expected interface{} }{ { "filter then map", "map(x => x * 2)(filter(x => x > 1)([1, 2, 3]))", []interface{}{int64(4), int64(6)}, }, { "map then filter", "filter(x => x > 3)(map(x => x * 2)([1, 2, 3]))", []interface{}{int64(4), int64(6)}, }, { "sort then reverse", "reverse(sort([3, 1, 2]))", []interface{}{int64(3), int64(2), int64(1)}, }, { "flatten then uniq", "uniq(flatten([[1, 2], [2, 3]]))", []interface{}{int64(1), int64(2), int64(3)}, }, { "groupBy then keys", "keys(groupBy(x => x % 2)([1, 2, 3, 4])).sort()", []interface{}{"0", "1"}, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { result, err := vm.RunString(tc.code) require.NoError(t, err) assert.Equal(t, tc.expected, result.Export()) }) } } // TestEdgeCases tests various edge cases. func TestEdgeCases(t *testing.T) { vm := setupVM(t) t.Run("deeply nested walk", func(t *testing.T) { result, err := vm.RunString(` walk(x => typeof x === 'number' ? x * 2 : x)({ a: { b: { c: 1 } } }) `) require.NoError(t, err) expected := map[string]interface{}{ "a": map[string]interface{}{ "b": map[string]interface{}{ "c": int64(2), }, }, } assert.Equal(t, expected, result.Export()) }) t.Run("empty input handling", func(t *testing.T) { tests := []string{ "len([])", "len({})", "len('')", "uniq([])", "sort([])", "flatten([])", "reverse([])", "filter(x => true)([])", "map(x => x)([])", "chunk(2)([])", "zip([], [])", "keys({})", "values({})", } for _, code := range tests { _, err := vm.RunString(code) assert.NoError(t, err, "Failed for: %s", code) } }) t.Run("large array handling", func(t *testing.T) { result, err := vm.RunString(` const arr = []; for (let i = 0; i < 1000; i++) arr.push(i); len(arr) `) require.NoError(t, err) assert.Equal(t, int64(1000), result.Export()) }) t.Run("unicode strings", func(t *testing.T) { result, err := vm.RunString(`len('你好世界')`) require.NoError(t, err) assert.Equal(t, int64(4), result.Export()) }) } // TestBase64RoundTrip tests that base64 encoding/decoding is reversible. func TestBase64RoundTrip(t *testing.T) { vm := setupVM(t) tests := []string{ "hello", "", "Hello, 世界!", "Special chars: !@#$%^&*()", strings.Repeat("a", 1000), } for _, input := range tests { t.Run(input[:min(len(input), 20)], func(t *testing.T) { code := "fromBase64(toBase64('" + escapeJS(input) + "'))" result, err := vm.RunString(code) require.NoError(t, err) assert.Equal(t, input, result.Export()) }) } } func escapeJS(s string) string { s = strings.ReplaceAll(s, "\\", "\\\\") s = strings.ReplaceAll(s, "'", "\\'") s = strings.ReplaceAll(s, "\n", "\\n") s = strings.ReplaceAll(s, "\r", "\\r") return s } func min(a, b int) int { if a < b { return a } return b } // TestExit tests the exit function. func TestExit(t *testing.T) { vm := setupVM(t) t.Run("exit panics with ExitError", func(t *testing.T) { defer func() { r := recover() require.NotNil(t, r, "Expected panic from exit()") exitErr, ok := r.(engine.ExitError) require.True(t, ok, "Expected ExitError, got %T", r) assert.Equal(t, 42, exitErr.Code) }() _, _ = vm.RunString("exit(42)") }) t.Run("exit with 0", func(t *testing.T) { defer func() { r := recover() require.NotNil(t, r) exitErr, ok := r.(engine.ExitError) require.True(t, ok) assert.Equal(t, 0, exitErr.Code) }() _, _ = vm.RunString("exit(0)") }) } // TestSave tests the save function. func TestSave(t *testing.T) { vm := setupVM(t) t.Run("save undefined throws error", func(t *testing.T) { _, err := vm.RunString("save(undefined)") assert.Error(t, err) assert.Contains(t, err.Error(), "Cannot save undefined") }) t.Run("save without file path throws error", func(t *testing.T) { // FilePath is empty by default in tests _, err := vm.RunString("save({a: 1})") assert.Error(t, err) assert.Contains(t, err.Error(), "specify a file") }) } // TestSortMutation tests that sort mutates the original array (JavaScript behavior). func TestSortMutation(t *testing.T) { vm := setupVM(t) t.Run("sort mutates original array", func(t *testing.T) { result, err := vm.RunString(` const arr = [3, 1, 2]; sort(arr); arr[0] `) require.NoError(t, err) // JavaScript sort mutates the array assert.Equal(t, int64(1), result.Export()) }) } // TestReverseMutation tests that reverse mutates the original array (JavaScript behavior). func TestReverseMutation(t *testing.T) { vm := setupVM(t) t.Run("reverse mutates original array", func(t *testing.T) { result, err := vm.RunString(` const arr = [1, 2, 3]; reverse(arr); arr[0] `) require.NoError(t, err) // JavaScript reverse mutates the array assert.Equal(t, int64(3), result.Export()) }) } // TestDelImmutability tests that del does not mutate the original. func TestDelImmutability(t *testing.T) { vm := setupVM(t) t.Run("del does not mutate original object", func(t *testing.T) { result, err := vm.RunString(` const obj = {a: 1, b: 2}; del('a')(obj); obj.a `) require.NoError(t, err) assert.Equal(t, int64(1), result.Export()) }) t.Run("del does not mutate original array", func(t *testing.T) { result, err := vm.RunString(` const arr = [1, 2, 3]; del(0)(arr); arr[0] `) require.NoError(t, err) assert.Equal(t, int64(1), result.Export()) }) } // TestWalkWithNull tests walk behavior with null values. func TestWalkWithNull(t *testing.T) { vm := setupVM(t) t.Run("walk handles null values", func(t *testing.T) { result, err := vm.RunString(` walk(x => x === null ? 'was null' : x)({a: null, b: 1}) `) require.NoError(t, err) expected := map[string]interface{}{"a": "was null", "b": int64(1)} assert.Equal(t, expected, result.Export()) }) } // TestGroupByWithPrototypeKeys tests groupBy doesn't have prototype pollution issues. func TestGroupByWithPrototypeKeys(t *testing.T) { vm := setupVM(t) t.Run("groupBy with hasOwnProperty key", func(t *testing.T) { result, err := vm.RunString(` groupBy(x => x)(['hasOwnProperty', 'toString', 'normal']) `) require.NoError(t, err) exported := result.Export().(map[string]interface{}) assert.Len(t, exported["hasOwnProperty"], 1) assert.Len(t, exported["toString"], 1) assert.Len(t, exported["normal"], 1) }) } // TestChunkEdgeCases tests chunk with edge case sizes. func TestChunkEdgeCases(t *testing.T) { vm := setupVM(t) t.Run("chunk size larger than array", func(t *testing.T) { result, err := vm.RunString("chunk(10)([1, 2, 3])") require.NoError(t, err) expected := []interface{}{[]interface{}{int64(1), int64(2), int64(3)}} assert.Equal(t, expected, result.Export()) }) t.Run("chunk size equals array length", func(t *testing.T) { result, err := vm.RunString("chunk(3)([1, 2, 3])") require.NoError(t, err) expected := []interface{}{[]interface{}{int64(1), int64(2), int64(3)}} assert.Equal(t, expected, result.Export()) }) } // TestZipEdgeCases tests zip with edge cases. func TestZipEdgeCases(t *testing.T) { vm := setupVM(t) t.Run("zip single array", func(t *testing.T) { result, err := vm.RunString("zip([1, 2, 3])") require.NoError(t, err) expected := []interface{}{ []interface{}{int64(1)}, []interface{}{int64(2)}, []interface{}{int64(3)}, } assert.Equal(t, expected, result.Export()) }) t.Run("zip with one empty array", func(t *testing.T) { result, err := vm.RunString("zip([1, 2, 3], [])") require.NoError(t, err) expected := []interface{}{} assert.Equal(t, expected, result.Export()) }) } // TestFilterWithObjects tests filter with object predicates. func TestFilterWithObjects(t *testing.T) { vm := setupVM(t) t.Run("filter objects by property", func(t *testing.T) { result, err := vm.RunString(` filter(x => x.active)([ {name: 'a', active: true}, {name: 'b', active: false}, {name: 'c', active: true} ]) `) require.NoError(t, err) exported := result.Export().([]interface{}) assert.Len(t, exported, 2) }) } // TestMapWithObjects tests map transforming objects. func TestMapWithObjects(t *testing.T) { vm := setupVM(t) t.Run("map extract property", func(t *testing.T) { result, err := vm.RunString(` map(x => x.name)([ {name: 'a', value: 1}, {name: 'b', value: 2} ]) `) require.NoError(t, err) expected := []interface{}{"a", "b"} assert.Equal(t, expected, result.Export()) }) t.Run("map transform object", func(t *testing.T) { result, err := vm.RunString(` map(x => ({...x, doubled: x.value * 2}))([ {value: 1}, {value: 2} ]) `) require.NoError(t, err) exported := result.Export().([]interface{}) assert.Equal(t, int64(2), exported[0].(map[string]interface{})["doubled"]) assert.Equal(t, int64(4), exported[1].(map[string]interface{})["doubled"]) }) } // TestSortByStability tests sortBy with equal keys. func TestSortByStability(t *testing.T) { vm := setupVM(t) t.Run("sortBy preserves order for equal keys", func(t *testing.T) { // Note: JavaScript sort is not guaranteed to be stable, but this tests the behavior result, err := vm.RunString(` sortBy(x => x.group)([ {name: 'a', group: 1}, {name: 'b', group: 2}, {name: 'c', group: 1} ]).map(x => x.name) `) require.NoError(t, err) exported := result.Export().([]interface{}) // Group 1 items should come before group 2 assert.Equal(t, "b", exported[2]) }) } // TestNestedOperations tests deeply nested function compositions. func TestNestedOperations(t *testing.T) { vm := setupVM(t) t.Run("complex nested operations", func(t *testing.T) { result, err := vm.RunString(` map(x => x * 2)( filter(x => x > 0)( flatten([ [-1, 0, 1], [2, 3] ]) ) ) `) require.NoError(t, err) expected := []interface{}{int64(2), int64(4), int64(6)} assert.Equal(t, expected, result.Export()) }) } ================================================ FILE: internal/engine/stringify.go ================================================ package engine import ( "fmt" "math" "math/big" "reflect" "strings" "time" "github.com/dop251/goja" ) func Stringify(value goja.Value, vm *goja.Runtime, depth int) string { rtype := value.ExportType() if rtype == nil { // Convert both null and undefined to null (save as JSON.stringify) return "null" } switch rtype { case bigIntType: bi := value.Export().(*big.Int) return bi.String() case timeTimeType: t := value.Export().(time.Time) quoted := Quote(t.String()) return quoted } switch rtype.Kind() { case reflect.Bool: if value.ToBoolean() { return "true" } else { return "false" } case reflect.Int64: return value.String() case reflect.Float64: f := value.ToFloat() if math.IsInf(f, 0) { return value.String() } else if math.IsNaN(f) { return value.String() } return value.String() case reflect.String: return Quote(value.String()) case reflect.Map: obj := value.ToObject(vm) keys := obj.Keys() if len(keys) == 0 { return "{}" } var out strings.Builder out.WriteString("{") out.WriteString("\n") ident := strings.Repeat(" ", depth) identKey := strings.Repeat(" ", depth+1) for i, key := range keys { out.WriteString(identKey) out.WriteString(Quote(key)) out.WriteString(":") out.WriteString(" ") out.WriteString(Stringify(obj.Get(key), vm, depth+1)) if i < len(keys)-1 { out.WriteString(",") } out.WriteString("\n") } out.WriteString(ident) out.WriteString("}") return out.String() case reflect.Slice: arr := value.ToObject(vm) keys := arr.Keys() if len(keys) == 0 { return "[]" } var out strings.Builder out.WriteString("[") out.WriteString("\n") for i, key := range keys { item := arr.Get(key) out.WriteString(strings.Repeat(" ", depth+1)) out.WriteString(Stringify(item, vm, depth+1)) if i < len(keys)-1 { out.WriteString(",") } out.WriteString("\n") } out.WriteString(strings.Repeat(" ", depth)) out.WriteString("]") return out.String() } panic(fmt.Sprintf("Unsupported value type: %v", rtype.Kind())) } ================================================ FILE: internal/engine/transpile.go ================================================ package engine import ( "fmt" "regexp" "strconv" "strings" ) func JS(args []string) string { var code strings.Builder code.WriteString("\nfunction __main__(json) {\n") for i := range args { if args[i] == "" { // In autocomplete: after dropTail, we can have empty strings. continue } code.WriteString(Body(args, i)) } code.WriteString("\n return json\n}\n") return code.String() } func Body(args []string, i int) string { jsCode := transpile(args[i]) snippet := formatErr(args, i, jsCode) return fmt.Sprintf(` try { json = apply((function () { const x = this return %s }).call(json), json) } catch (e) { throw %s } if (json === skip) return skip `, jsCode, strconv.Quote(snippet)+" + e.toString()") } var ( reBracket = regexp.MustCompile(`^(\.\w*)+\[]`) reBracketStart = regexp.MustCompile(`^\.\[`) reDotStart = regexp.MustCompile(`^\.`) reAt = regexp.MustCompile(`^@`) reFilter = regexp.MustCompile(`^\?`) ) func transpile(code string) string { if code == "." { return "x" } if reBracket.MatchString(code) { return fmt.Sprintf("(%s)(x)", fold(strings.Split(code, "[]"))) } if reBracketStart.MatchString(code) { return "x" + code[1:] } if reDotStart.MatchString(code) { return "x" + code } if reAt.MatchString(code) { jsCode := transpile(code[1:]) return fmt.Sprintf(`map((x, i) => apply(%s, x, i))`, jsCode) } if reFilter.MatchString(code) { jsCode := transpile(code[1:]) return fmt.Sprintf(`filter((x, i) => apply(%s, x, i))`, jsCode) } return code } func fold(s []string) string { if len(s) == 1 { return "x => x" + s[0] } obj := s[0] s = s[1:] if obj == "." { obj = "x" } else { obj = "x" + obj } return fmt.Sprintf(`x => %s.flatMap(%s)`, obj, fold(s)) } ================================================ FILE: internal/engine/transpile_test.go ================================================ package engine import ( "testing" ) func TestTranspile(t *testing.T) { tests := []struct { code string want string }{ {".", "x"}, {".foo", "x.foo"}, {".[0]", "x[0]"}, {"foo", "foo"}, {"@.baz", "map((x, i) => apply(x.baz, x, i))"}, {"?.foo > 42", "filter((x, i) => apply(x.foo > 42, x, i))"}, {".foo[].bar[]", "(x => x.foo.flatMap(x => x.bar.flatMap(x => x)))(x)"}, } for _, tt := range tests { t.Run(tt.code, func(t *testing.T) { got := transpile(tt.code) if got != tt.want { t.Errorf("transpile(%q) = %q; want %q", tt.code, got, tt.want) } }) } } func TestFoldSimple(t *testing.T) { tests := []struct { parts []string want string }{ {[]string{".foo"}, "x => x.foo"}, {[]string{".foo", ".bar"}, "x => x.foo.flatMap(x => x.bar)"}, } for _, tt := range tests { got := fold(tt.parts) if got != tt.want { t.Errorf("fold(%v) = %q; want %q", tt.parts, got, tt.want) } } } ================================================ FILE: internal/engine/utils.go ================================================ package engine import ( "math/big" "reflect" "regexp" "time" "github.com/dop251/goja" ) var ( bigIntType = reflect.TypeOf((*big.Int)(nil)) timeTimeType = reflect.TypeOf((*time.Time)(nil)).Elem() ) var ( syntaxErrorRe = regexp.MustCompile(`^SyntaxError: SyntaxError: \(anonymous\): Line \d+:\d+\s+`) andMoreErrors = regexp.MustCompile(`\(and \d+ more errors\)$`) ) func extractErrorMessage(s string) string { s = syntaxErrorRe.ReplaceAllString(s, "") s = andMoreErrors.ReplaceAllString(s, "") return s } func errorToString(err error) string { if exception, ok := err.(*goja.Exception); ok { message := exception.Value().String() message = extractErrorMessage(message) return message } return err.Error() } ================================================ FILE: internal/engine/vm.go ================================================ package engine import ( "encoding/base64" "fmt" "os" "github.com/dop251/goja" "github.com/goccy/go-yaml" ) // FilePath is the file being processed, empty if stdin. var FilePath string // ExitError is used by exit() to signal a specific exit code. type ExitError struct { Code int } func NewVM(writeOut func(string)) *goja.Runtime { vm := goja.New() if err := vm.Set("println", func(s string) any { writeOut(s) return nil }); err != nil { panic(err) } if err := vm.Set("__save__", func(json string) error { if FilePath == "" { return fmt.Errorf("specify a file as the first argument to be able to save: fx file.json ") } if info, err := os.Lstat(FilePath); err == nil && info.Mode()&os.ModeSymlink != 0 { return fmt.Errorf("cannot save to a symbolic link: %s", FilePath) } if err := os.WriteFile(FilePath, []byte(json), 0644); err != nil { return err } return nil }); err != nil { panic(err) } if err := vm.Set("__stringify__", func(x goja.Value) string { return Stringify(x, vm, 0) + "\n" }); err != nil { panic(err) } if err := vm.Set("__toBase64__", func(x string) string { return base64.StdEncoding.EncodeToString([]byte(x)) }); err != nil { panic(err) } if err := vm.Set("__fromBase64__", func(x string) (string, error) { decoded, err := base64.StdEncoding.DecodeString(x) if err != nil { return "", err } return string(decoded), err }); err != nil { panic(err) } if err := vm.Set("__yaml_parse__", func(x string) (string, error) { b, err := yaml.YAMLToJSON([]byte(x)) if err != nil { return "", err } return string(b), err }); err != nil { panic(err) } if err := vm.Set("__yaml_stringify__", func(x goja.Value) string { b, err := yaml.JSONToYAML([]byte(Stringify(x, vm, 0))) if err != nil { return "" } return string(b) }); err != nil { panic(err) } if err := vm.Set("__exit__", func(code int) { panic(ExitError{Code: code}) }); err != nil { panic(err) } return vm } ================================================ FILE: internal/fuzzy/algo.go ================================================ package fuzzy import ( "bytes" "strings" "unicode" "unicode/utf8" ) var delimiterChars = "." const whiteChars = " \t\n\v\f\r\x85\xA0" type Result struct { Start int End int Score int } const ( scoreMatch = 16 scoreGapStart = -3 scoreGapExtension = -1 // We prefer matches at the beginning of a word, but the bonus should not be // too great to prevent the longer acronym matches from always winning over // shorter fuzzy matches. The bonus point here was specifically chosen that // the bonus is cancelled when the gap between the acronyms grows over // 8 characters, which is approximately the average length of the words found // in web2 dictionary and my file system. bonusBoundary = scoreMatch / 2 // Although bonus point for non-word characters is non-contextual, we need it // for computing bonus points for consecutive chunks starting with a non-word // character. bonusNonWord = scoreMatch / 2 // Edge-triggered bonus for matches in camelCase words. // Compared to word-boundary case, they don't accompany single-character gaps // (e.g. FooBar vs. foo-bar), so we deduct bonus point accordingly. bonusCamel123 = bonusBoundary + scoreGapExtension // Minimum bonus point given to characters in consecutive chunks. // Note that bonus points for consecutive matches shouldn't have needed if we // used fixed match score as in the original algorithm. bonusConsecutive = -(scoreGapStart + scoreGapExtension) // The first character in the typed pattern usually has more significance // than the rest so it's important that it appears at special positions where // bonus points are given, e.g. "to-go" vs. "ongoing" on "og" or on "ogo". // The amount of the extra bonus should be limited so that the gap penalty is // still respected. bonusFirstCharMultiplier = 2 ) var ( // Extra bonus for word boundary after whitespace character or beginning of the string bonusBoundaryWhite int16 = bonusBoundary // Extra bonus for word boundary after slash, colon, semi-colon, and comma bonusBoundaryDelimiter int16 = bonusBoundary + 1 initialCharClass = charDelimiter // A minor optimization that can give 15%+ performance boost asciiCharClasses [unicode.MaxASCII + 1]charClass // A minor optimization that can give yet another 5% performance boost bonusMatrix [charNumber + 1][charNumber + 1]int16 ) type charClass int const ( charWhite charClass = iota charNonWord charDelimiter charLower charUpper charLetter charNumber ) func init() { for i := 0; i <= unicode.MaxASCII; i++ { char := rune(i) c := charNonWord if char >= 'a' && char <= 'z' { c = charLower } else if char >= 'A' && char <= 'Z' { c = charUpper } else if char >= '0' && char <= '9' { c = charNumber } else if strings.ContainsRune(whiteChars, char) { c = charWhite } else if strings.ContainsRune(delimiterChars, char) { c = charDelimiter } asciiCharClasses[i] = c } for i := 0; i <= int(charNumber); i++ { for j := 0; j <= int(charNumber); j++ { bonusMatrix[i][j] = bonusFor(charClass(i), charClass(j)) } } } func posArray(withPos bool, len int) *[]int { if withPos { pos := make([]int, 0, len) return &pos } return nil } func alloc16(offset int, slab *Slab, size int) (int, []int16) { if slab != nil && cap(slab.I16) > offset+size { slice := slab.I16[offset : offset+size] return offset + size, slice } return offset, make([]int16, size) } func alloc32(offset int, slab *Slab, size int) (int, []int32) { if slab != nil && cap(slab.I32) > offset+size { slice := slab.I32[offset : offset+size] return offset + size, slice } return offset, make([]int32, size) } func charClassOfNonAscii(char rune) charClass { if unicode.IsLower(char) { return charLower } else if unicode.IsUpper(char) { return charUpper } else if unicode.IsNumber(char) { return charNumber } else if unicode.IsLetter(char) { return charLetter } else if unicode.IsSpace(char) { return charWhite } else if strings.ContainsRune(delimiterChars, char) { return charDelimiter } return charNonWord } func charClassOf(char rune) charClass { if char <= unicode.MaxASCII { return asciiCharClasses[char] } return charClassOfNonAscii(char) } func bonusFor(prevClass charClass, class charClass) int16 { if class > charNonWord { switch prevClass { case charWhite: // Word boundary after whitespace return bonusBoundaryWhite case charDelimiter: // Word boundary after a delimiter character return bonusBoundaryDelimiter case charNonWord: // Word boundary return bonusBoundary } } if prevClass == charLower && class == charUpper || prevClass != charNumber && class == charNumber { // camelCase letter123 return bonusCamel123 } switch class { case charNonWord, charDelimiter: return bonusNonWord case charWhite: return bonusBoundaryWhite } return 0 } func normalizeRune(r rune) rune { if r < 0x00C0 || r > 0x2184 { return r } n := normalized[r] if n > 0 { return n } return r } func trySkip(input *Chars, caseSensitive bool, b byte, from int) int { byteArray := input.Bytes()[from:] idx := bytes.IndexByte(byteArray, b) if idx == 0 { // Can't skip any further return from } // We may need to search for the uppercase letter again. We don't have to // consider normalization as we can be sure that this is an ASCII string. if !caseSensitive && b >= 'a' && b <= 'z' { if idx > 0 { byteArray = byteArray[:idx] } uidx := bytes.IndexByte(byteArray, b-32) if uidx >= 0 { idx = uidx } } if idx < 0 { return -1 } return from + idx } func isAscii(runes []rune) bool { for _, r := range runes { if r >= utf8.RuneSelf { return false } } return true } func asciiFuzzyIndex(input *Chars, pattern []rune, caseSensitive bool) (int, int) { // Can't determine if !input.IsBytes() { return 0, input.Length() } // Not possible if !isAscii(pattern) { return -1, -1 } firstIdx, idx, lastIdx := 0, 0, 0 var b byte for pidx := 0; pidx < len(pattern); pidx++ { b = byte(pattern[pidx]) idx = trySkip(input, caseSensitive, b, idx) if idx < 0 { return -1, -1 } if pidx == 0 && idx > 0 { // Step back to find the right bonus point firstIdx = idx - 1 } lastIdx = idx idx++ } // Find the last appearance of the last character of the pattern to limit the search scope bu := b if !caseSensitive && b >= 'a' && b <= 'z' { bu = b - 32 } scope := input.Bytes()[lastIdx:] for offset := len(scope) - 1; offset > 0; offset-- { if scope[offset] == b || scope[offset] == bu { return firstIdx, lastIdx + offset + 1 } } return firstIdx, lastIdx + 1 } type Slab struct { I16 []int16 I32 []int32 } const ( caseSensitive = false normalize = true forward = true ) func fuzzyMatch(input *Chars, pattern []rune) (Result, *[]int) { var slab *Slab // Assume that pattern is given in lowercase if case-insensitive. // First check if there's a match and calculate bonus for each position. // If the input string is too long, consider finding the matching chars in // this phase as well (non-optimal alignment). M := len(pattern) if M == 0 { return Result{0, 0, 0}, posArray(true, M) } N := input.Length() if M > N { return Result{-1, -1, 0}, nil } // Phase 1. Optimized search for ASCII string minIdx, maxIdx := asciiFuzzyIndex(input, pattern, caseSensitive) if minIdx < 0 { return Result{-1, -1, 0}, nil } // fmt.Println(N, maxIdx, idx, maxIdx-idx, input.ToString()) N = maxIdx - minIdx // Reuse pre-allocated integer slice to avoid unnecessary sweeping of garbages offset16 := 0 offset32 := 0 offset16, H0 := alloc16(offset16, slab, N) offset16, C0 := alloc16(offset16, slab, N) // Bonus point for each position offset16, B := alloc16(offset16, slab, N) // The first occurrence of each character in the pattern offset32, F := alloc32(offset32, slab, M) // Rune array _, T := alloc32(offset32, slab, N) input.CopyRunes(T, minIdx) // Phase 2. Calculate bonus for each point maxScore, maxScorePos := int16(0), 0 pidx, lastIdx := 0, 0 pchar0, pchar, prevH0, prevClass, inGap := pattern[0], pattern[0], int16(0), initialCharClass, false for off, char := range T { var class charClass if char <= unicode.MaxASCII { class = asciiCharClasses[char] if !caseSensitive && class == charUpper { char += 32 T[off] = char } } else { class = charClassOfNonAscii(char) if !caseSensitive && class == charUpper { char = unicode.To(unicode.LowerCase, char) } if normalize { char = normalizeRune(char) } T[off] = char } bonus := bonusMatrix[prevClass][class] B[off] = bonus prevClass = class if char == pchar { if pidx < M { F[pidx] = int32(off) pidx++ pchar = pattern[min(pidx, M-1)] } lastIdx = off } if char == pchar0 { score := scoreMatch + bonus*bonusFirstCharMultiplier H0[off] = score C0[off] = 1 if M == 1 && (forward && score > maxScore || !forward && score >= maxScore) { maxScore, maxScorePos = score, off if forward && bonus >= bonusBoundary { break } } inGap = false } else { if inGap { H0[off] = max(prevH0+scoreGapExtension, 0) } else { H0[off] = max(prevH0+scoreGapStart, 0) } C0[off] = 0 inGap = true } prevH0 = H0[off] } if pidx != M { return Result{-1, -1, 0}, nil } if M == 1 { result := Result{minIdx + maxScorePos, minIdx + maxScorePos + 1, int(maxScore)} pos := []int{minIdx + maxScorePos} return result, &pos } // Phase 3. Fill in score matrix (H) // Unlike the original algorithm, we do not allow omission. f0 := int(F[0]) width := lastIdx - f0 + 1 offset16, H := alloc16(offset16, slab, width*M) copy(H, H0[f0:lastIdx+1]) // Possible length of consecutive chunk at each position. _, C := alloc16(offset16, slab, width*M) copy(C, C0[f0:lastIdx+1]) Fsub := F[1:] Psub := pattern[1:][:len(Fsub)] for off, f := range Fsub { f := int(f) pchar := Psub[off] pidx := off + 1 row := pidx * width inGap := false Tsub := T[f : lastIdx+1] Bsub := B[f:][:len(Tsub)] Csub := C[row+f-f0:][:len(Tsub)] Cdiag := C[row+f-f0-1-width:][:len(Tsub)] Hsub := H[row+f-f0:][:len(Tsub)] Hdiag := H[row+f-f0-1-width:][:len(Tsub)] Hleft := H[row+f-f0-1:][:len(Tsub)] Hleft[0] = 0 for off, char := range Tsub { col := off + f var s1, s2, consecutive int16 if inGap { s2 = Hleft[off] + scoreGapExtension } else { s2 = Hleft[off] + scoreGapStart } if pchar == char { s1 = Hdiag[off] + scoreMatch b := Bsub[off] consecutive = Cdiag[off] + 1 if consecutive > 1 { fb := B[col-int(consecutive)+1] // Break consecutive chunk if b >= bonusBoundary && b > fb { consecutive = 1 } else { b = max(b, max(bonusConsecutive, fb)) } } if s1+b < s2 { s1 += Bsub[off] consecutive = 0 } else { s1 += b } } Csub[off] = consecutive inGap = s1 < s2 score := max(max(s1, s2), 0) if pidx == M-1 && (forward && score > maxScore || !forward && score >= maxScore) { maxScore, maxScorePos = score, col } Hsub[off] = score } } // Phase 4. (Optional) Backtrace to find character positions pos := posArray(true, M) j := f0 i := M - 1 j = maxScorePos preferMatch := true for { I := i * width j0 := j - f0 s := H[I+j0] var s1, s2 int16 if i > 0 && j >= int(F[i]) { s1 = H[I-width+j0-1] } if j > int(F[i]) { s2 = H[I+j0-1] } if s > s1 && (s > s2 || s == s2 && preferMatch) { *pos = append(*pos, j+minIdx) if i == 0 { break } i-- } preferMatch = C[I+j0] > 1 || I+width+j0+1 < len(C) && C[I+width+j0+1] > 0 j-- } // Start offset we return here is only relevant when begin tiebreak is used. // However finding the accurate offset requires backtracking, and we don't // want to pay extra cost for the option that has lost its importance. return Result{minIdx + j, minIdx + maxScorePos + 1, int(maxScore)}, pos } ================================================ FILE: internal/fuzzy/chars.go ================================================ package fuzzy import ( "bytes" "unicode" "unicode/utf8" "unsafe" ) const ( overflow64 uint64 = 0x8080808080808080 overflow32 uint32 = 0x80808080 ) type Chars struct { slice []byte // or []rune inBytes bool trimLengthKnown bool trimLength uint16 // XXX Piggybacking item index here is a horrible idea. But I'm trying to // minimize the memory footprint by not wasting padded spaces. Index int32 } func checkAscii(bytes []byte) (bool, int) { i := 0 for ; i <= len(bytes)-8; i += 8 { if (overflow64 & *(*uint64)(unsafe.Pointer(&bytes[i]))) > 0 { return false, i } } for ; i <= len(bytes)-4; i += 4 { if (overflow32 & *(*uint32)(unsafe.Pointer(&bytes[i]))) > 0 { return false, i } } for ; i < len(bytes); i++ { if bytes[i] >= utf8.RuneSelf { return false, i } } return true, 0 } // ToChars converts byte array into rune array func ToChars(bytes []byte) Chars { inBytes, bytesUntil := checkAscii(bytes) if inBytes { return Chars{slice: bytes, inBytes: inBytes} } runes := make([]rune, bytesUntil, len(bytes)) for i := 0; i < bytesUntil; i++ { runes[i] = rune(bytes[i]) } for i := bytesUntil; i < len(bytes); { r, sz := utf8.DecodeRune(bytes[i:]) i += sz runes = append(runes, r) } return RunesToChars(runes) } func RunesToChars(runes []rune) Chars { return Chars{slice: *(*[]byte)(unsafe.Pointer(&runes)), inBytes: false} } func (chars *Chars) IsBytes() bool { return chars.inBytes } func (chars *Chars) Bytes() []byte { return chars.slice } func (chars *Chars) NumLines(atMost int) (int, bool) { lines := 1 if runes := chars.optionalRunes(); runes != nil { for _, r := range runes { if r == '\n' { lines++ } if lines > atMost { return atMost, true } } return lines, false } for idx := 0; idx < len(chars.slice); idx++ { found := bytes.IndexByte(chars.slice[idx:], '\n') if found < 0 { break } idx += found lines++ if lines > atMost { return atMost, true } } return lines, false } func (chars *Chars) optionalRunes() []rune { if chars.inBytes { return nil } return *(*[]rune)(unsafe.Pointer(&chars.slice)) } func (chars *Chars) Get(i int) rune { if runes := chars.optionalRunes(); runes != nil { return runes[i] } return rune(chars.slice[i]) } func (chars *Chars) Length() int { if runes := chars.optionalRunes(); runes != nil { return len(runes) } return len(chars.slice) } // TrimLength returns the length after trimming leading and trailing whitespaces func (chars *Chars) TrimLength() uint16 { if chars.trimLengthKnown { return chars.trimLength } chars.trimLengthKnown = true var i int len := chars.Length() for i = len - 1; i >= 0; i-- { char := chars.Get(i) if !unicode.IsSpace(char) { break } } // Completely empty if i < 0 { return 0 } var j int for j = 0; j < len; j++ { char := chars.Get(j) if !unicode.IsSpace(char) { break } } chars.trimLength = AsUint16(i - j + 1) return chars.trimLength } func (chars *Chars) LeadingWhitespaces() int { whitespaces := 0 for i := 0; i < chars.Length(); i++ { char := chars.Get(i) if !unicode.IsSpace(char) { break } whitespaces++ } return whitespaces } func (chars *Chars) TrailingWhitespaces() int { whitespaces := 0 for i := chars.Length() - 1; i >= 0; i-- { char := chars.Get(i) if !unicode.IsSpace(char) { break } whitespaces++ } return whitespaces } func (chars *Chars) TrimTrailingWhitespaces() { whitespaces := chars.TrailingWhitespaces() chars.slice = chars.slice[0 : len(chars.slice)-whitespaces] } func (chars *Chars) TrimSuffix(runes []rune) { lastIdx := len(chars.slice) firstIdx := lastIdx - len(runes) if firstIdx < 0 { return } for i := firstIdx; i < lastIdx; i++ { char := chars.Get(i) if char != runes[i-firstIdx] { return } } chars.slice = chars.slice[0:firstIdx] } func (chars *Chars) SliceRight(last int) { chars.slice = chars.slice[:last] } func (chars *Chars) ToString() string { if runes := chars.optionalRunes(); runes != nil { return string(runes) } return unsafe.String(unsafe.SliceData(chars.slice), len(chars.slice)) } func (chars *Chars) ToRunes() []rune { if runes := chars.optionalRunes(); runes != nil { return runes } bytes := chars.slice runes := make([]rune, len(bytes)) for idx, b := range bytes { runes[idx] = rune(b) } return runes } func (chars *Chars) CopyRunes(dest []rune, from int) { if runes := chars.optionalRunes(); runes != nil { copy(dest, runes[from:]) return } for idx, b := range chars.slice[from:][:len(dest)] { dest[idx] = rune(b) } } func (chars *Chars) Prepend(prefix string) { if runes := chars.optionalRunes(); runes != nil { runes = append([]rune(prefix), runes...) chars.slice = *(*[]byte)(unsafe.Pointer(&runes)) } else { chars.slice = append([]byte(prefix), chars.slice...) } } func (chars *Chars) Lines(multiLine bool, maxLines int, wrapCols int, wrapSignWidth int, tabstop int) ([][]rune, bool) { text := make([]rune, chars.Length()) copy(text, chars.ToRunes()) lines := [][]rune{} overflow := false if !multiLine { lines = append(lines, text) } else { from := 0 for off := 0; off < len(text); off++ { if text[off] == '\n' { lines = append(lines, text[from:off+1]) // Include '\n' from = off + 1 if len(lines) >= maxLines { break } } } var lastLine []rune if from < len(text) { lastLine = text[from:] } overflow = false if len(lines) >= maxLines { overflow = true } else { lines = append(lines, lastLine) } } // If wrapping is disabled, we're done if wrapCols == 0 { return lines, overflow } wrapped := [][]rune{} for _, line := range lines { // Remove trailing '\n' and remember if it was there newline := len(line) > 0 && line[len(line)-1] == '\n' if newline { line = line[:len(line)-1] } hasWrapSign := false for { cols := wrapCols if hasWrapSign { cols -= wrapSignWidth } _, overflowIdx := RunesWidth(line, 0, tabstop, cols) if overflowIdx >= 0 { // Might be a wide character if overflowIdx == 0 { overflowIdx = 1 } if len(wrapped) >= maxLines { return wrapped, true } wrapped = append(wrapped, line[:overflowIdx]) hasWrapSign = true line = line[overflowIdx:] continue } hasWrapSign = false // Restore trailing '\n' if newline { line = append(line, '\n') } if len(wrapped) >= maxLines { return wrapped, true } wrapped = append(wrapped, line) break } } return wrapped, overflow } ================================================ FILE: internal/fuzzy/chars_test.go ================================================ package fuzzy import ( "fmt" "testing" ) func TestToCharsAscii(t *testing.T) { chars := ToChars([]byte("foobar")) if !chars.inBytes || chars.ToString() != "foobar" || !chars.inBytes { t.Error() } } func TestCharsLength(t *testing.T) { chars := ToChars([]byte("\tabc한글 ")) if chars.inBytes || chars.Length() != 8 || chars.TrimLength() != 5 { t.Error() } } func TestCharsToString(t *testing.T) { text := "\tabc한글 " chars := ToChars([]byte(text)) if chars.ToString() != text { t.Error() } } func TestTrimLength(t *testing.T) { check := func(str string, exp uint16) { chars := ToChars([]byte(str)) trimmed := chars.TrimLength() if trimmed != exp { t.Errorf("Invalid TrimLength result for '%s': %d (expected %d)", str, trimmed, exp) } } check("hello", 5) check("hello ", 5) check("hello ", 5) check(" hello", 5) check(" hello", 5) check(" hello ", 5) check(" hello ", 5) check("h o", 5) check(" h o ", 5) check(" ", 0) } func TestCharsLines(t *testing.T) { chars := ToChars([]byte("abcdef\n가나다\n\tdef")) check := func(multiLine bool, maxLines int, wrapCols int, wrapSignWidth int, tabstop int, expectedNumLines int, expectedOverflow bool) { lines, overflow := chars.Lines(multiLine, maxLines, wrapCols, wrapSignWidth, tabstop) fmt.Println(lines, overflow) if len(lines) != expectedNumLines || overflow != expectedOverflow { t.Errorf("Invalid result: %d %v (expected %d %v)", len(lines), overflow, expectedNumLines, expectedOverflow) } } // No wrap check(true, 1, 0, 0, 8, 1, true) check(true, 2, 0, 0, 8, 2, true) check(true, 3, 0, 0, 8, 3, false) // Wrap (2) check(true, 4, 2, 0, 8, 4, true) check(true, 5, 2, 0, 8, 5, true) check(true, 6, 2, 0, 8, 6, true) check(true, 7, 2, 0, 8, 7, true) check(true, 8, 2, 0, 8, 8, true) check(true, 9, 2, 0, 8, 9, false) check(true, 9, 2, 0, 1, 8, false) // Smaller tab size // With wrap sign (3 + 1) check(true, 100, 3, 1, 1, 8, false) // With wrap sign (3 + 2) check(true, 100, 3, 2, 1, 10, false) // With wrap sign (3 + 2) and no multi-line check(false, 100, 3, 2, 1, 13, false) } ================================================ FILE: internal/fuzzy/find.go ================================================ package fuzzy type Match struct { Index int Str string Score int Pos []int } func Find(pattern []rune, array []string) *Match { var result Result var pos *[]int foundIndex := -1 for i := range array { input := ToChars([]byte(array[i])) r, p := fuzzyMatch(&input, pattern) if r.Score > result.Score { result = r pos = p foundIndex = i } } if foundIndex >= 0 && pos != nil { return &Match{ Index: foundIndex, Str: array[foundIndex], Score: result.Score, Pos: *pos, } } return nil } ================================================ FILE: internal/fuzzy/fuzzy_test.go ================================================ package fuzzy import ( "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestFind(t *testing.T) { tests := []struct { name string pattern string array []string expectNil bool expectIdx int expectStr string }{ { name: "exact match", pattern: "hello", array: []string{"world", "hello", "foo"}, expectNil: false, expectIdx: 1, expectStr: "hello", }, { name: "fuzzy match", pattern: "hlo", array: []string{"world", "hello", "foo"}, expectNil: false, expectIdx: 1, expectStr: "hello", }, { name: "no match", pattern: "xyz", array: []string{"hello", "world", "foo"}, expectNil: true, }, { name: "empty array", pattern: "hello", array: []string{}, expectNil: true, }, { name: "empty pattern", pattern: "", array: []string{"hello", "world"}, expectNil: true, // Empty pattern returns nil }, { name: "single char pattern", pattern: "w", array: []string{"hello", "world"}, expectNil: false, expectIdx: 1, expectStr: "world", }, { name: "best match selected", pattern: "foo", array: []string{"foobar", "foo", "barfoo"}, expectNil: false, expectIdx: 0, // Algorithm scores "foobar" highest due to consecutive bonus expectStr: "foobar", }, { name: "case insensitive", pattern: "hello", array: []string{"HELLO", "world"}, expectNil: false, expectIdx: 0, expectStr: "HELLO", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { result := Find([]rune(tc.pattern), tc.array) if tc.expectNil { assert.Nil(t, result) } else { require.NotNil(t, result) assert.Equal(t, tc.expectIdx, result.Index) assert.Equal(t, tc.expectStr, result.Str) assert.GreaterOrEqual(t, result.Score, 0) } }) } } func TestFuzzyMatch(t *testing.T) { tests := []struct { name string input string pattern string expectMatch bool }{ {"exact match", "hello", "hello", true}, {"prefix match", "hello", "hel", true}, {"suffix match", "hello", "llo", true}, {"scattered match", "hello", "hlo", true}, {"no match", "hello", "xyz", false}, {"empty pattern", "hello", "", true}, {"pattern longer than input", "hi", "hello", false}, {"case insensitive", "HELLO", "hello", true}, {"unicode input", "你好世界", "好界", true}, {"unicode no match", "你好世界", "abc", false}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { input := ToChars([]byte(tc.input)) result, pos := fuzzyMatch(&input, []rune(tc.pattern)) if tc.expectMatch { assert.GreaterOrEqual(t, result.Start, 0, "Expected match but got Start=-1") if tc.pattern != "" { assert.NotNil(t, pos) } } else { assert.Equal(t, -1, result.Start, "Expected no match but got Start>=0") } }) } } func TestFuzzyMatchScoring(t *testing.T) { input1 := ToChars([]byte("foobar")) input2 := ToChars([]byte("foo_bar")) // Exact prefix should score higher result1, _ := fuzzyMatch(&input1, []rune("foo")) result2, _ := fuzzyMatch(&input2, []rune("foo")) assert.Greater(t, result1.Score, 0) assert.Greater(t, result2.Score, 0) } func TestNormalizeRune(t *testing.T) { tests := []struct { name string input rune expected rune }{ {"ascii a", 'a', 'a'}, {"ascii A", 'A', 'A'}, {"ascii 0", '0', '0'}, {"a with acute", 'á', 'a'}, {"a with grave", 'à', 'a'}, {"a with circumflex", 'â', 'a'}, {"e with acute", 'é', 'e'}, {"o with umlaut", 'ö', 'o'}, {"n with tilde", 'ñ', 'n'}, {"c with cedilla", 'ç', 'c'}, // Capital letters {"A with acute", 'Á', 'A'}, {"O with umlaut", 'Ö', 'O'}, // Characters outside normalization range {"chinese", '中', '中'}, {"emoji", '😀', '😀'}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { result := normalizeRune(tc.input) assert.Equal(t, tc.expected, result) }) } } func TestNormalizeRunes(t *testing.T) { tests := []struct { name string input []rune expected []rune }{ {"ascii only", []rune("hello"), []rune("hello")}, {"with accents", []rune("héllo"), []rune("hello")}, {"all accents", []rune("àéîõü"), []rune("aeiou")}, {"mixed", []rune("café"), []rune("cafe")}, {"empty", []rune(""), []rune("")}, {"no change needed", []rune("test123"), []rune("test123")}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { result := NormalizeRunes(tc.input) assert.Equal(t, tc.expected, result) }) } } func TestCharClass(t *testing.T) { tests := []struct { name string char rune expected charClass }{ {"lowercase a", 'a', charLower}, {"lowercase z", 'z', charLower}, {"uppercase A", 'A', charUpper}, {"uppercase Z", 'Z', charUpper}, {"digit 0", '0', charNumber}, {"digit 9", '9', charNumber}, {"space", ' ', charWhite}, {"tab", '\t', charWhite}, {"newline", '\n', charWhite}, {"dot delimiter", '.', charDelimiter}, {"special char", '@', charNonWord}, {"underscore", '_', charNonWord}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { result := charClassOf(tc.char) assert.Equal(t, tc.expected, result) }) } } func TestCharClassOfNonAscii(t *testing.T) { tests := []struct { name string char rune expected charClass }{ {"unicode lower", 'ä', charLower}, {"unicode upper", 'Ä', charUpper}, {"unicode number", '①', charNumber}, {"unicode letter", '中', charLetter}, {"unicode space", '\u00A0', charWhite}, // non-breaking space } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { result := charClassOfNonAscii(tc.char) assert.Equal(t, tc.expected, result) }) } } func TestBonusFor(t *testing.T) { // Test word boundary bonuses assert.Equal(t, bonusBoundaryWhite, bonusFor(charWhite, charLower)) assert.Equal(t, bonusBoundaryDelimiter, bonusFor(charDelimiter, charLower)) assert.Equal(t, int16(bonusBoundary), bonusFor(charNonWord, charLower)) // Test camelCase bonus assert.Equal(t, int16(bonusCamel123), bonusFor(charLower, charUpper)) // Test number after non-number assert.Equal(t, int16(bonusCamel123), bonusFor(charLower, charNumber)) assert.Equal(t, int16(0), bonusFor(charNumber, charNumber)) } func TestAsUint16(t *testing.T) { tests := []struct { name string input int expected uint16 }{ {"zero", 0, 0}, {"positive", 100, 100}, {"max uint16", 65535, 65535}, {"above max", 70000, 65535}, {"negative", -1, 0}, {"large negative", -1000, 0}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { result := AsUint16(tc.input) assert.Equal(t, tc.expected, result) }) } } func TestStringWidth(t *testing.T) { tests := []struct { name string input string expected int }{ {"ascii", "hello", 5}, {"empty", "", 0}, {"with newline", "a\nb", 3}, // 2 chars + 1 for newline {"with cr", "a\rb", 3}, {"wide chars", "你好", 4}, // each Chinese char is 2 columns {"mixed", "ab你好cd", 8}, // 4 ascii + 4 wide } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { result := StringWidth(tc.input) assert.Equal(t, tc.expected, result) }) } } func TestRunesWidth(t *testing.T) { tests := []struct { name string runes []rune prefixWidth int tabstop int limit int expectWidth int expectOverIdx int }{ { name: "ascii within limit", runes: []rune("hello"), prefixWidth: 0, tabstop: 8, limit: 10, expectWidth: 5, expectOverIdx: -1, }, { name: "ascii exceeds limit", runes: []rune("hello"), prefixWidth: 0, tabstop: 8, limit: 3, expectWidth: 4, expectOverIdx: 3, }, { name: "tab expansion", runes: []rune("\t"), prefixWidth: 0, tabstop: 8, limit: 10, expectWidth: 8, expectOverIdx: -1, }, { name: "tab with prefix", runes: []rune("\t"), prefixWidth: 3, tabstop: 8, limit: 10, expectWidth: 5, // 8 - 3 = 5 expectOverIdx: -1, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { width, overIdx := RunesWidth(tc.runes, tc.prefixWidth, tc.tabstop, tc.limit) assert.Equal(t, tc.expectWidth, width) assert.Equal(t, tc.expectOverIdx, overIdx) }) } } func TestTrySkip(t *testing.T) { tests := []struct { name string input string caseSensitive bool b byte from int expected int }{ {"find at start", "hello", false, 'h', 0, 0}, {"find in middle", "hello", false, 'l', 0, 2}, {"find from offset", "hello", false, 'l', 3, 3}, {"not found", "hello", false, 'x', 0, -1}, {"case insensitive upper", "HELLO", false, 'h', 0, 0}, {"case sensitive no match", "HELLO", true, 'h', 0, -1}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { input := ToChars([]byte(tc.input)) result := trySkip(&input, tc.caseSensitive, tc.b, tc.from) assert.Equal(t, tc.expected, result) }) } } func TestIsAscii(t *testing.T) { tests := []struct { name string runes []rune expected bool }{ {"ascii only", []rune("hello"), true}, {"empty", []rune(""), true}, {"with unicode", []rune("hello世界"), false}, {"unicode only", []rune("世界"), false}, {"edge of ascii", []rune{127}, true}, {"beyond ascii", []rune{128}, false}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { result := isAscii(tc.runes) assert.Equal(t, tc.expected, result) }) } } func TestAsciiFuzzyIndex(t *testing.T) { tests := []struct { name string input string pattern string caseSensitive bool expectMin int expectMax int }{ {"exact match", "hello", "hello", false, 0, 5}, {"partial match", "hello world", "wor", false, 5, 9}, {"no match", "hello", "xyz", false, -1, -1}, {"case insensitive", "HELLO", "hel", false, 0, 3}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { input := ToChars([]byte(tc.input)) minIdx, maxIdx := asciiFuzzyIndex(&input, []rune(tc.pattern), tc.caseSensitive) assert.Equal(t, tc.expectMin, minIdx) if tc.expectMin >= 0 { assert.GreaterOrEqual(t, maxIdx, tc.expectMax) } }) } } ================================================ FILE: internal/fuzzy/normalize.go ================================================ // Normalization of latin script letters // Reference: http://www.unicode.org/Public/UCD/latest/ucd/Index.txt package fuzzy var normalized = map[rune]rune{ 0x00E1: 'a', // WITH ACUTE, LATIN SMALL LETTER 0x0103: 'a', // WITH BREVE, LATIN SMALL LETTER 0x01CE: 'a', // WITH CARON, LATIN SMALL LETTER 0x00E2: 'a', // WITH CIRCUMFLEX, LATIN SMALL LETTER 0x00E4: 'a', // WITH DIAERESIS, LATIN SMALL LETTER 0x0227: 'a', // WITH DOT ABOVE, LATIN SMALL LETTER 0x1EA1: 'a', // WITH DOT BELOW, LATIN SMALL LETTER 0x0201: 'a', // WITH DOUBLE GRAVE, LATIN SMALL LETTER 0x00E0: 'a', // WITH GRAVE, LATIN SMALL LETTER 0x1EA3: 'a', // WITH HOOK ABOVE, LATIN SMALL LETTER 0x0203: 'a', // WITH INVERTED BREVE, LATIN SMALL LETTER 0x0101: 'a', // WITH MACRON, LATIN SMALL LETTER 0x0105: 'a', // WITH OGONEK, LATIN SMALL LETTER 0x1E9A: 'a', // WITH RIGHT HALF RING, LATIN SMALL LETTER 0x00E5: 'a', // WITH RING ABOVE, LATIN SMALL LETTER 0x1E01: 'a', // WITH RING BELOW, LATIN SMALL LETTER 0x00E3: 'a', // WITH TILDE, LATIN SMALL LETTER 0x0363: 'a', // , COMBINING LATIN SMALL LETTER 0x0250: 'a', // , LATIN SMALL LETTER TURNED 0x1E03: 'b', // WITH DOT ABOVE, LATIN SMALL LETTER 0x1E05: 'b', // WITH DOT BELOW, LATIN SMALL LETTER 0x0253: 'b', // WITH HOOK, LATIN SMALL LETTER 0x1E07: 'b', // WITH LINE BELOW, LATIN SMALL LETTER 0x0180: 'b', // WITH STROKE, LATIN SMALL LETTER 0x0183: 'b', // WITH TOPBAR, LATIN SMALL LETTER 0x0107: 'c', // WITH ACUTE, LATIN SMALL LETTER 0x010D: 'c', // WITH CARON, LATIN SMALL LETTER 0x00E7: 'c', // WITH CEDILLA, LATIN SMALL LETTER 0x0109: 'c', // WITH CIRCUMFLEX, LATIN SMALL LETTER 0x0255: 'c', // WITH CURL, LATIN SMALL LETTER 0x010B: 'c', // WITH DOT ABOVE, LATIN SMALL LETTER 0x0188: 'c', // WITH HOOK, LATIN SMALL LETTER 0x023C: 'c', // WITH STROKE, LATIN SMALL LETTER 0x0368: 'c', // , COMBINING LATIN SMALL LETTER 0x0297: 'c', // , LATIN LETTER STRETCHED 0x2184: 'c', // , LATIN SMALL LETTER REVERSED 0x010F: 'd', // WITH CARON, LATIN SMALL LETTER 0x1E11: 'd', // WITH CEDILLA, LATIN SMALL LETTER 0x1E13: 'd', // WITH CIRCUMFLEX BELOW, LATIN SMALL LETTER 0x0221: 'd', // WITH CURL, LATIN SMALL LETTER 0x1E0B: 'd', // WITH DOT ABOVE, LATIN SMALL LETTER 0x1E0D: 'd', // WITH DOT BELOW, LATIN SMALL LETTER 0x0257: 'd', // WITH HOOK, LATIN SMALL LETTER 0x1E0F: 'd', // WITH LINE BELOW, LATIN SMALL LETTER 0x0111: 'd', // WITH STROKE, LATIN SMALL LETTER 0x0256: 'd', // WITH TAIL, LATIN SMALL LETTER 0x018C: 'd', // WITH TOPBAR, LATIN SMALL LETTER 0x0369: 'd', // , COMBINING LATIN SMALL LETTER 0x00E9: 'e', // WITH ACUTE, LATIN SMALL LETTER 0x0115: 'e', // WITH BREVE, LATIN SMALL LETTER 0x011B: 'e', // WITH CARON, LATIN SMALL LETTER 0x0229: 'e', // WITH CEDILLA, LATIN SMALL LETTER 0x1E19: 'e', // WITH CIRCUMFLEX BELOW, LATIN SMALL LETTER 0x00EA: 'e', // WITH CIRCUMFLEX, LATIN SMALL LETTER 0x00EB: 'e', // WITH DIAERESIS, LATIN SMALL LETTER 0x0117: 'e', // WITH DOT ABOVE, LATIN SMALL LETTER 0x1EB9: 'e', // WITH DOT BELOW, LATIN SMALL LETTER 0x0205: 'e', // WITH DOUBLE GRAVE, LATIN SMALL LETTER 0x00E8: 'e', // WITH GRAVE, LATIN SMALL LETTER 0x1EBB: 'e', // WITH HOOK ABOVE, LATIN SMALL LETTER 0x025D: 'e', // WITH HOOK, LATIN SMALL LETTER REVERSED OPEN 0x0207: 'e', // WITH INVERTED BREVE, LATIN SMALL LETTER 0x0113: 'e', // WITH MACRON, LATIN SMALL LETTER 0x0119: 'e', // WITH OGONEK, LATIN SMALL LETTER 0x0247: 'e', // WITH STROKE, LATIN SMALL LETTER 0x1E1B: 'e', // WITH TILDE BELOW, LATIN SMALL LETTER 0x1EBD: 'e', // WITH TILDE, LATIN SMALL LETTER 0x0364: 'e', // , COMBINING LATIN SMALL LETTER 0x029A: 'e', // , LATIN SMALL LETTER CLOSED OPEN 0x025E: 'e', // , LATIN SMALL LETTER CLOSED REVERSED OPEN 0x025B: 'e', // , LATIN SMALL LETTER OPEN 0x0258: 'e', // , LATIN SMALL LETTER REVERSED 0x025C: 'e', // , LATIN SMALL LETTER REVERSED OPEN 0x01DD: 'e', // , LATIN SMALL LETTER TURNED 0x1D08: 'e', // , LATIN SMALL LETTER TURNED OPEN 0x1E1F: 'f', // WITH DOT ABOVE, LATIN SMALL LETTER 0x0192: 'f', // WITH HOOK, LATIN SMALL LETTER 0x01F5: 'g', // WITH ACUTE, LATIN SMALL LETTER 0x011F: 'g', // WITH BREVE, LATIN SMALL LETTER 0x01E7: 'g', // WITH CARON, LATIN SMALL LETTER 0x0123: 'g', // WITH CEDILLA, LATIN SMALL LETTER 0x011D: 'g', // WITH CIRCUMFLEX, LATIN SMALL LETTER 0x0121: 'g', // WITH DOT ABOVE, LATIN SMALL LETTER 0x0260: 'g', // WITH HOOK, LATIN SMALL LETTER 0x1E21: 'g', // WITH MACRON, LATIN SMALL LETTER 0x01E5: 'g', // WITH STROKE, LATIN SMALL LETTER 0x0261: 'g', // , LATIN SMALL LETTER SCRIPT 0x1E2B: 'h', // WITH BREVE BELOW, LATIN SMALL LETTER 0x021F: 'h', // WITH CARON, LATIN SMALL LETTER 0x1E29: 'h', // WITH CEDILLA, LATIN SMALL LETTER 0x0125: 'h', // WITH CIRCUMFLEX, LATIN SMALL LETTER 0x1E27: 'h', // WITH DIAERESIS, LATIN SMALL LETTER 0x1E23: 'h', // WITH DOT ABOVE, LATIN SMALL LETTER 0x1E25: 'h', // WITH DOT BELOW, LATIN SMALL LETTER 0x02AE: 'h', // WITH FISHHOOK, LATIN SMALL LETTER TURNED 0x0266: 'h', // WITH HOOK, LATIN SMALL LETTER 0x1E96: 'h', // WITH LINE BELOW, LATIN SMALL LETTER 0x0127: 'h', // WITH STROKE, LATIN SMALL LETTER 0x036A: 'h', // , COMBINING LATIN SMALL LETTER 0x0265: 'h', // , LATIN SMALL LETTER TURNED 0x2095: 'h', // , LATIN SUBSCRIPT SMALL LETTER 0x00ED: 'i', // WITH ACUTE, LATIN SMALL LETTER 0x012D: 'i', // WITH BREVE, LATIN SMALL LETTER 0x01D0: 'i', // WITH CARON, LATIN SMALL LETTER 0x00EE: 'i', // WITH CIRCUMFLEX, LATIN SMALL LETTER 0x00EF: 'i', // WITH DIAERESIS, LATIN SMALL LETTER 0x1ECB: 'i', // WITH DOT BELOW, LATIN SMALL LETTER 0x0209: 'i', // WITH DOUBLE GRAVE, LATIN SMALL LETTER 0x00EC: 'i', // WITH GRAVE, LATIN SMALL LETTER 0x1EC9: 'i', // WITH HOOK ABOVE, LATIN SMALL LETTER 0x020B: 'i', // WITH INVERTED BREVE, LATIN SMALL LETTER 0x012B: 'i', // WITH MACRON, LATIN SMALL LETTER 0x012F: 'i', // WITH OGONEK, LATIN SMALL LETTER 0x0268: 'i', // WITH STROKE, LATIN SMALL LETTER 0x1E2D: 'i', // WITH TILDE BELOW, LATIN SMALL LETTER 0x0129: 'i', // WITH TILDE, LATIN SMALL LETTER 0x0365: 'i', // , COMBINING LATIN SMALL LETTER 0x0131: 'i', // , LATIN SMALL LETTER DOTLESS 0x1D09: 'i', // , LATIN SMALL LETTER TURNED 0x1D62: 'i', // , LATIN SUBSCRIPT SMALL LETTER 0x2071: 'i', // , SUPERSCRIPT LATIN SMALL LETTER 0x01F0: 'j', // WITH CARON, LATIN SMALL LETTER 0x0135: 'j', // WITH CIRCUMFLEX, LATIN SMALL LETTER 0x029D: 'j', // WITH CROSSED-TAIL, LATIN SMALL LETTER 0x0249: 'j', // WITH STROKE, LATIN SMALL LETTER 0x025F: 'j', // WITH STROKE, LATIN SMALL LETTER DOTLESS 0x0237: 'j', // , LATIN SMALL LETTER DOTLESS 0x1E31: 'k', // WITH ACUTE, LATIN SMALL LETTER 0x01E9: 'k', // WITH CARON, LATIN SMALL LETTER 0x0137: 'k', // WITH CEDILLA, LATIN SMALL LETTER 0x1E33: 'k', // WITH DOT BELOW, LATIN SMALL LETTER 0x0199: 'k', // WITH HOOK, LATIN SMALL LETTER 0x1E35: 'k', // WITH LINE BELOW, LATIN SMALL LETTER 0x029E: 'k', // , LATIN SMALL LETTER TURNED 0x2096: 'k', // , LATIN SUBSCRIPT SMALL LETTER 0x013A: 'l', // WITH ACUTE, LATIN SMALL LETTER 0x019A: 'l', // WITH BAR, LATIN SMALL LETTER 0x026C: 'l', // WITH BELT, LATIN SMALL LETTER 0x013E: 'l', // WITH CARON, LATIN SMALL LETTER 0x013C: 'l', // WITH CEDILLA, LATIN SMALL LETTER 0x1E3D: 'l', // WITH CIRCUMFLEX BELOW, LATIN SMALL LETTER 0x0234: 'l', // WITH CURL, LATIN SMALL LETTER 0x1E37: 'l', // WITH DOT BELOW, LATIN SMALL LETTER 0x1E3B: 'l', // WITH LINE BELOW, LATIN SMALL LETTER 0x0140: 'l', // WITH MIDDLE DOT, LATIN SMALL LETTER 0x026B: 'l', // WITH MIDDLE TILDE, LATIN SMALL LETTER 0x026D: 'l', // WITH RETROFLEX HOOK, LATIN SMALL LETTER 0x0142: 'l', // WITH STROKE, LATIN SMALL LETTER 0x2097: 'l', // , LATIN SUBSCRIPT SMALL LETTER 0x1E3F: 'm', // WITH ACUTE, LATIN SMALL LETTER 0x1E41: 'm', // WITH DOT ABOVE, LATIN SMALL LETTER 0x1E43: 'm', // WITH DOT BELOW, LATIN SMALL LETTER 0x0271: 'm', // WITH HOOK, LATIN SMALL LETTER 0x0270: 'm', // WITH LONG LEG, LATIN SMALL LETTER TURNED 0x036B: 'm', // , COMBINING LATIN SMALL LETTER 0x1D1F: 'm', // , LATIN SMALL LETTER SIDEWAYS TURNED 0x026F: 'm', // , LATIN SMALL LETTER TURNED 0x2098: 'm', // , LATIN SUBSCRIPT SMALL LETTER 0x0144: 'n', // WITH ACUTE, LATIN SMALL LETTER 0x0148: 'n', // WITH CARON, LATIN SMALL LETTER 0x0146: 'n', // WITH CEDILLA, LATIN SMALL LETTER 0x1E4B: 'n', // WITH CIRCUMFLEX BELOW, LATIN SMALL LETTER 0x0235: 'n', // WITH CURL, LATIN SMALL LETTER 0x1E45: 'n', // WITH DOT ABOVE, LATIN SMALL LETTER 0x1E47: 'n', // WITH DOT BELOW, LATIN SMALL LETTER 0x01F9: 'n', // WITH GRAVE, LATIN SMALL LETTER 0x0272: 'n', // WITH LEFT HOOK, LATIN SMALL LETTER 0x1E49: 'n', // WITH LINE BELOW, LATIN SMALL LETTER 0x019E: 'n', // WITH LONG RIGHT LEG, LATIN SMALL LETTER 0x0273: 'n', // WITH RETROFLEX HOOK, LATIN SMALL LETTER 0x00F1: 'n', // WITH TILDE, LATIN SMALL LETTER 0x2099: 'n', // , LATIN SUBSCRIPT SMALL LETTER 0x00F3: 'o', // WITH ACUTE, LATIN SMALL LETTER 0x014F: 'o', // WITH BREVE, LATIN SMALL LETTER 0x01D2: 'o', // WITH CARON, LATIN SMALL LETTER 0x00F4: 'o', // WITH CIRCUMFLEX, LATIN SMALL LETTER 0x00F6: 'o', // WITH DIAERESIS, LATIN SMALL LETTER 0x022F: 'o', // WITH DOT ABOVE, LATIN SMALL LETTER 0x1ECD: 'o', // WITH DOT BELOW, LATIN SMALL LETTER 0x0151: 'o', // WITH DOUBLE ACUTE, LATIN SMALL LETTER 0x020D: 'o', // WITH DOUBLE GRAVE, LATIN SMALL LETTER 0x00F2: 'o', // WITH GRAVE, LATIN SMALL LETTER 0x1ECF: 'o', // WITH HOOK ABOVE, LATIN SMALL LETTER 0x01A1: 'o', // WITH HORN, LATIN SMALL LETTER 0x020F: 'o', // WITH INVERTED BREVE, LATIN SMALL LETTER 0x014D: 'o', // WITH MACRON, LATIN SMALL LETTER 0x01EB: 'o', // WITH OGONEK, LATIN SMALL LETTER 0x00F8: 'o', // WITH STROKE, LATIN SMALL LETTER 0x1D13: 'o', // WITH STROKE, LATIN SMALL LETTER SIDEWAYS 0x00F5: 'o', // WITH TILDE, LATIN SMALL LETTER 0x0366: 'o', // , COMBINING LATIN SMALL LETTER 0x0275: 'o', // , LATIN SMALL LETTER BARRED 0x1D17: 'o', // , LATIN SMALL LETTER BOTTOM HALF 0x0254: 'o', // , LATIN SMALL LETTER OPEN 0x1D11: 'o', // , LATIN SMALL LETTER SIDEWAYS 0x1D12: 'o', // , LATIN SMALL LETTER SIDEWAYS OPEN 0x1D16: 'o', // , LATIN SMALL LETTER TOP HALF 0x1E55: 'p', // WITH ACUTE, LATIN SMALL LETTER 0x1E57: 'p', // WITH DOT ABOVE, LATIN SMALL LETTER 0x01A5: 'p', // WITH HOOK, LATIN SMALL LETTER 0x209A: 'p', // , LATIN SUBSCRIPT SMALL LETTER 0x024B: 'q', // WITH HOOK TAIL, LATIN SMALL LETTER 0x02A0: 'q', // WITH HOOK, LATIN SMALL LETTER 0x0155: 'r', // WITH ACUTE, LATIN SMALL LETTER 0x0159: 'r', // WITH CARON, LATIN SMALL LETTER 0x0157: 'r', // WITH CEDILLA, LATIN SMALL LETTER 0x1E59: 'r', // WITH DOT ABOVE, LATIN SMALL LETTER 0x1E5B: 'r', // WITH DOT BELOW, LATIN SMALL LETTER 0x0211: 'r', // WITH DOUBLE GRAVE, LATIN SMALL LETTER 0x027E: 'r', // WITH FISHHOOK, LATIN SMALL LETTER 0x027F: 'r', // WITH FISHHOOK, LATIN SMALL LETTER REVERSED 0x027B: 'r', // WITH HOOK, LATIN SMALL LETTER TURNED 0x0213: 'r', // WITH INVERTED BREVE, LATIN SMALL LETTER 0x1E5F: 'r', // WITH LINE BELOW, LATIN SMALL LETTER 0x027C: 'r', // WITH LONG LEG, LATIN SMALL LETTER 0x027A: 'r', // WITH LONG LEG, LATIN SMALL LETTER TURNED 0x024D: 'r', // WITH STROKE, LATIN SMALL LETTER 0x027D: 'r', // WITH TAIL, LATIN SMALL LETTER 0x036C: 'r', // , COMBINING LATIN SMALL LETTER 0x0279: 'r', // , LATIN SMALL LETTER TURNED 0x1D63: 'r', // , LATIN SUBSCRIPT SMALL LETTER 0x015B: 's', // WITH ACUTE, LATIN SMALL LETTER 0x0161: 's', // WITH CARON, LATIN SMALL LETTER 0x015F: 's', // WITH CEDILLA, LATIN SMALL LETTER 0x015D: 's', // WITH CIRCUMFLEX, LATIN SMALL LETTER 0x0219: 's', // WITH COMMA BELOW, LATIN SMALL LETTER 0x1E61: 's', // WITH DOT ABOVE, LATIN SMALL LETTER 0x1E9B: 's', // WITH DOT ABOVE, LATIN SMALL LETTER LONG 0x1E63: 's', // WITH DOT BELOW, LATIN SMALL LETTER 0x0282: 's', // WITH HOOK, LATIN SMALL LETTER 0x023F: 's', // WITH SWASH TAIL, LATIN SMALL LETTER 0x017F: 's', // , LATIN SMALL LETTER LONG 0x00DF: 's', // , LATIN SMALL LETTER SHARP 0x209B: 's', // , LATIN SUBSCRIPT SMALL LETTER 0x0165: 't', // WITH CARON, LATIN SMALL LETTER 0x0163: 't', // WITH CEDILLA, LATIN SMALL LETTER 0x1E71: 't', // WITH CIRCUMFLEX BELOW, LATIN SMALL LETTER 0x021B: 't', // WITH COMMA BELOW, LATIN SMALL LETTER 0x0236: 't', // WITH CURL, LATIN SMALL LETTER 0x1E97: 't', // WITH DIAERESIS, LATIN SMALL LETTER 0x1E6B: 't', // WITH DOT ABOVE, LATIN SMALL LETTER 0x1E6D: 't', // WITH DOT BELOW, LATIN SMALL LETTER 0x01AD: 't', // WITH HOOK, LATIN SMALL LETTER 0x1E6F: 't', // WITH LINE BELOW, LATIN SMALL LETTER 0x01AB: 't', // WITH PALATAL HOOK, LATIN SMALL LETTER 0x0288: 't', // WITH RETROFLEX HOOK, LATIN SMALL LETTER 0x0167: 't', // WITH STROKE, LATIN SMALL LETTER 0x036D: 't', // , COMBINING LATIN SMALL LETTER 0x0287: 't', // , LATIN SMALL LETTER TURNED 0x209C: 't', // , LATIN SUBSCRIPT SMALL LETTER 0x0289: 'u', // BAR, LATIN SMALL LETTER 0x00FA: 'u', // WITH ACUTE, LATIN SMALL LETTER 0x016D: 'u', // WITH BREVE, LATIN SMALL LETTER 0x01D4: 'u', // WITH CARON, LATIN SMALL LETTER 0x1E77: 'u', // WITH CIRCUMFLEX BELOW, LATIN SMALL LETTER 0x00FB: 'u', // WITH CIRCUMFLEX, LATIN SMALL LETTER 0x1E73: 'u', // WITH DIAERESIS BELOW, LATIN SMALL LETTER 0x00FC: 'u', // WITH DIAERESIS, LATIN SMALL LETTER 0x1EE5: 'u', // WITH DOT BELOW, LATIN SMALL LETTER 0x0171: 'u', // WITH DOUBLE ACUTE, LATIN SMALL LETTER 0x0215: 'u', // WITH DOUBLE GRAVE, LATIN SMALL LETTER 0x00F9: 'u', // WITH GRAVE, LATIN SMALL LETTER 0x1EE7: 'u', // WITH HOOK ABOVE, LATIN SMALL LETTER 0x01B0: 'u', // WITH HORN, LATIN SMALL LETTER 0x0217: 'u', // WITH INVERTED BREVE, LATIN SMALL LETTER 0x016B: 'u', // WITH MACRON, LATIN SMALL LETTER 0x0173: 'u', // WITH OGONEK, LATIN SMALL LETTER 0x016F: 'u', // WITH RING ABOVE, LATIN SMALL LETTER 0x1E75: 'u', // WITH TILDE BELOW, LATIN SMALL LETTER 0x0169: 'u', // WITH TILDE, LATIN SMALL LETTER 0x0367: 'u', // , COMBINING LATIN SMALL LETTER 0x1D1D: 'u', // , LATIN SMALL LETTER SIDEWAYS 0x1D1E: 'u', // , LATIN SMALL LETTER SIDEWAYS DIAERESIZED 0x1D64: 'u', // , LATIN SUBSCRIPT SMALL LETTER 0x1E7F: 'v', // WITH DOT BELOW, LATIN SMALL LETTER 0x028B: 'v', // WITH HOOK, LATIN SMALL LETTER 0x1E7D: 'v', // WITH TILDE, LATIN SMALL LETTER 0x036E: 'v', // , COMBINING LATIN SMALL LETTER 0x028C: 'v', // , LATIN SMALL LETTER TURNED 0x1D65: 'v', // , LATIN SUBSCRIPT SMALL LETTER 0x1E83: 'w', // WITH ACUTE, LATIN SMALL LETTER 0x0175: 'w', // WITH CIRCUMFLEX, LATIN SMALL LETTER 0x1E85: 'w', // WITH DIAERESIS, LATIN SMALL LETTER 0x1E87: 'w', // WITH DOT ABOVE, LATIN SMALL LETTER 0x1E89: 'w', // WITH DOT BELOW, LATIN SMALL LETTER 0x1E81: 'w', // WITH GRAVE, LATIN SMALL LETTER 0x1E98: 'w', // WITH RING ABOVE, LATIN SMALL LETTER 0x028D: 'w', // , LATIN SMALL LETTER TURNED 0x1E8D: 'x', // WITH DIAERESIS, LATIN SMALL LETTER 0x1E8B: 'x', // WITH DOT ABOVE, LATIN SMALL LETTER 0x036F: 'x', // , COMBINING LATIN SMALL LETTER 0x00FD: 'y', // WITH ACUTE, LATIN SMALL LETTER 0x0177: 'y', // WITH CIRCUMFLEX, LATIN SMALL LETTER 0x00FF: 'y', // WITH DIAERESIS, LATIN SMALL LETTER 0x1E8F: 'y', // WITH DOT ABOVE, LATIN SMALL LETTER 0x1EF5: 'y', // WITH DOT BELOW, LATIN SMALL LETTER 0x1EF3: 'y', // WITH GRAVE, LATIN SMALL LETTER 0x1EF7: 'y', // WITH HOOK ABOVE, LATIN SMALL LETTER 0x01B4: 'y', // WITH HOOK, LATIN SMALL LETTER 0x0233: 'y', // WITH MACRON, LATIN SMALL LETTER 0x1E99: 'y', // WITH RING ABOVE, LATIN SMALL LETTER 0x024F: 'y', // WITH STROKE, LATIN SMALL LETTER 0x1EF9: 'y', // WITH TILDE, LATIN SMALL LETTER 0x028E: 'y', // , LATIN SMALL LETTER TURNED 0x017A: 'z', // WITH ACUTE, LATIN SMALL LETTER 0x017E: 'z', // WITH CARON, LATIN SMALL LETTER 0x1E91: 'z', // WITH CIRCUMFLEX, LATIN SMALL LETTER 0x0291: 'z', // WITH CURL, LATIN SMALL LETTER 0x017C: 'z', // WITH DOT ABOVE, LATIN SMALL LETTER 0x1E93: 'z', // WITH DOT BELOW, LATIN SMALL LETTER 0x0225: 'z', // WITH HOOK, LATIN SMALL LETTER 0x1E95: 'z', // WITH LINE BELOW, LATIN SMALL LETTER 0x0290: 'z', // WITH RETROFLEX HOOK, LATIN SMALL LETTER 0x01B6: 'z', // WITH STROKE, LATIN SMALL LETTER 0x0240: 'z', // WITH SWASH TAIL, LATIN SMALL LETTER 0x0251: 'a', // , latin small letter script 0x00C1: 'A', // WITH ACUTE, LATIN CAPITAL LETTER 0x00C2: 'A', // WITH CIRCUMFLEX, LATIN CAPITAL LETTER 0x00C4: 'A', // WITH DIAERESIS, LATIN CAPITAL LETTER 0x00C0: 'A', // WITH GRAVE, LATIN CAPITAL LETTER 0x00C5: 'A', // WITH RING ABOVE, LATIN CAPITAL LETTER 0x023A: 'A', // WITH STROKE, LATIN CAPITAL LETTER 0x00C3: 'A', // WITH TILDE, LATIN CAPITAL LETTER 0x1D00: 'A', // , LATIN LETTER SMALL CAPITAL 0x0181: 'B', // WITH HOOK, LATIN CAPITAL LETTER 0x0243: 'B', // WITH STROKE, LATIN CAPITAL LETTER 0x0299: 'B', // , LATIN LETTER SMALL CAPITAL 0x1D03: 'B', // , LATIN LETTER SMALL CAPITAL BARRED 0x00C7: 'C', // WITH CEDILLA, LATIN CAPITAL LETTER 0x023B: 'C', // WITH STROKE, LATIN CAPITAL LETTER 0x1D04: 'C', // , LATIN LETTER SMALL CAPITAL 0x018A: 'D', // WITH HOOK, LATIN CAPITAL LETTER 0x0189: 'D', // , LATIN CAPITAL LETTER AFRICAN 0x1D05: 'D', // , LATIN LETTER SMALL CAPITAL 0x00C9: 'E', // WITH ACUTE, LATIN CAPITAL LETTER 0x00CA: 'E', // WITH CIRCUMFLEX, LATIN CAPITAL LETTER 0x00CB: 'E', // WITH DIAERESIS, LATIN CAPITAL LETTER 0x00C8: 'E', // WITH GRAVE, LATIN CAPITAL LETTER 0x0246: 'E', // WITH STROKE, LATIN CAPITAL LETTER 0x0190: 'E', // , LATIN CAPITAL LETTER OPEN 0x018E: 'E', // , LATIN CAPITAL LETTER REVERSED 0x1D07: 'E', // , LATIN LETTER SMALL CAPITAL 0x0193: 'G', // WITH HOOK, LATIN CAPITAL LETTER 0x029B: 'G', // WITH HOOK, LATIN LETTER SMALL CAPITAL 0x0262: 'G', // , LATIN LETTER SMALL CAPITAL 0x029C: 'H', // , LATIN LETTER SMALL CAPITAL 0x00CD: 'I', // WITH ACUTE, LATIN CAPITAL LETTER 0x00CE: 'I', // WITH CIRCUMFLEX, LATIN CAPITAL LETTER 0x00CF: 'I', // WITH DIAERESIS, LATIN CAPITAL LETTER 0x0130: 'I', // WITH DOT ABOVE, LATIN CAPITAL LETTER 0x00CC: 'I', // WITH GRAVE, LATIN CAPITAL LETTER 0x0197: 'I', // WITH STROKE, LATIN CAPITAL LETTER 0x026A: 'I', // , LATIN LETTER SMALL CAPITAL 0x0248: 'J', // WITH STROKE, LATIN CAPITAL LETTER 0x1D0A: 'J', // , LATIN LETTER SMALL CAPITAL 0x1D0B: 'K', // , LATIN LETTER SMALL CAPITAL 0x023D: 'L', // WITH BAR, LATIN CAPITAL LETTER 0x1D0C: 'L', // WITH STROKE, LATIN LETTER SMALL CAPITAL 0x029F: 'L', // , LATIN LETTER SMALL CAPITAL 0x019C: 'M', // , LATIN CAPITAL LETTER TURNED 0x1D0D: 'M', // , LATIN LETTER SMALL CAPITAL 0x019D: 'N', // WITH LEFT HOOK, LATIN CAPITAL LETTER 0x0220: 'N', // WITH LONG RIGHT LEG, LATIN CAPITAL LETTER 0x00D1: 'N', // WITH TILDE, LATIN CAPITAL LETTER 0x0274: 'N', // , LATIN LETTER SMALL CAPITAL 0x1D0E: 'N', // , LATIN LETTER SMALL CAPITAL REVERSED 0x00D3: 'O', // WITH ACUTE, LATIN CAPITAL LETTER 0x00D4: 'O', // WITH CIRCUMFLEX, LATIN CAPITAL LETTER 0x00D6: 'O', // WITH DIAERESIS, LATIN CAPITAL LETTER 0x00D2: 'O', // WITH GRAVE, LATIN CAPITAL LETTER 0x019F: 'O', // WITH MIDDLE TILDE, LATIN CAPITAL LETTER 0x00D8: 'O', // WITH STROKE, LATIN CAPITAL LETTER 0x00D5: 'O', // WITH TILDE, LATIN CAPITAL LETTER 0x0186: 'O', // , LATIN CAPITAL LETTER OPEN 0x1D0F: 'O', // , LATIN LETTER SMALL CAPITAL 0x1D10: 'O', // , LATIN LETTER SMALL CAPITAL OPEN 0x1D18: 'P', // , LATIN LETTER SMALL CAPITAL 0x024A: 'Q', // WITH HOOK TAIL, LATIN CAPITAL LETTER SMALL 0x024C: 'R', // WITH STROKE, LATIN CAPITAL LETTER 0x0280: 'R', // , LATIN LETTER SMALL CAPITAL 0x0281: 'R', // , LATIN LETTER SMALL CAPITAL INVERTED 0x1D19: 'R', // , LATIN LETTER SMALL CAPITAL REVERSED 0x1D1A: 'R', // , LATIN LETTER SMALL CAPITAL TURNED 0x023E: 'T', // WITH DIAGONAL STROKE, LATIN CAPITAL LETTER 0x01AE: 'T', // WITH RETROFLEX HOOK, LATIN CAPITAL LETTER 0x1D1B: 'T', // , LATIN LETTER SMALL CAPITAL 0x0244: 'U', // BAR, LATIN CAPITAL LETTER 0x00DA: 'U', // WITH ACUTE, LATIN CAPITAL LETTER 0x00DB: 'U', // WITH CIRCUMFLEX, LATIN CAPITAL LETTER 0x00DC: 'U', // WITH DIAERESIS, LATIN CAPITAL LETTER 0x00D9: 'U', // WITH GRAVE, LATIN CAPITAL LETTER 0x1D1C: 'U', // , LATIN LETTER SMALL CAPITAL 0x01B2: 'V', // WITH HOOK, LATIN CAPITAL LETTER 0x0245: 'V', // , LATIN CAPITAL LETTER TURNED 0x1D20: 'V', // , LATIN LETTER SMALL CAPITAL 0x1D21: 'W', // , LATIN LETTER SMALL CAPITAL 0x00DD: 'Y', // WITH ACUTE, LATIN CAPITAL LETTER 0x0178: 'Y', // WITH DIAERESIS, LATIN CAPITAL LETTER 0x024E: 'Y', // WITH STROKE, LATIN CAPITAL LETTER 0x028F: 'Y', // , LATIN LETTER SMALL CAPITAL 0x1D22: 'Z', // , LATIN LETTER SMALL CAPITAL 'Ắ': 'A', 'Ấ': 'A', 'Ằ': 'A', 'Ầ': 'A', 'Ẳ': 'A', 'Ẩ': 'A', 'Ẵ': 'A', 'Ẫ': 'A', 'Ặ': 'A', 'Ậ': 'A', 'ắ': 'a', 'ấ': 'a', 'ằ': 'a', 'ầ': 'a', 'ẳ': 'a', 'ẩ': 'a', 'ẵ': 'a', 'ẫ': 'a', 'ặ': 'a', 'ậ': 'a', 'Ế': 'E', 'Ề': 'E', 'Ể': 'E', 'Ễ': 'E', 'Ệ': 'E', 'ế': 'e', 'ề': 'e', 'ể': 'e', 'ễ': 'e', 'ệ': 'e', 'Ố': 'O', 'Ớ': 'O', 'Ồ': 'O', 'Ờ': 'O', 'Ổ': 'O', 'Ở': 'O', 'Ỗ': 'O', 'Ỡ': 'O', 'Ộ': 'O', 'Ợ': 'O', 'ố': 'o', 'ớ': 'o', 'ồ': 'o', 'ờ': 'o', 'ổ': 'o', 'ở': 'o', 'ỗ': 'o', 'ỡ': 'o', 'ộ': 'o', 'ợ': 'o', 'Ứ': 'U', 'Ừ': 'U', 'Ử': 'U', 'Ữ': 'U', 'Ự': 'U', 'ứ': 'u', 'ừ': 'u', 'ử': 'u', 'ữ': 'u', 'ự': 'u', } // NormalizeRunes normalizes latin script letters func NormalizeRunes(runes []rune) []rune { ret := make([]rune, len(runes)) copy(ret, runes) for idx, r := range runes { if r < 0x00C0 || r > 0x2184 { continue } n := normalized[r] if n > 0 { ret[idx] = normalized[r] } } return ret } ================================================ FILE: internal/fuzzy/utils.go ================================================ package fuzzy import ( "math" "strings" "github.com/rivo/uniseg" ) func AsUint16(val int) uint16 { if val > math.MaxUint16 { return math.MaxUint16 } else if val < 0 { return 0 } return uint16(val) } // StringWidth returns string width where each CR/LF character takes 1 column func StringWidth(s string) int { return uniseg.StringWidth(s) + strings.Count(s, "\n") + strings.Count(s, "\r") } // RunesWidth returns runes width func RunesWidth(runes []rune, prefixWidth int, tabstop int, limit int) (int, int) { width := 0 gr := uniseg.NewGraphemes(string(runes)) idx := 0 for gr.Next() { rs := gr.Runes() var w int if len(rs) == 1 && rs[0] == '\t' { w = tabstop - (prefixWidth+width)%tabstop } else { w = StringWidth(string(rs)) } width += w if width > limit { return width, idx } idx += len(rs) } return width, -1 } ================================================ FILE: internal/ident/ident.go ================================================ package ident import ( "os" "strconv" "strings" ) var Ident = " " var IdentBytes []byte var IdentWidth int func init() { identValue, ok := os.LookupEnv("FX_INDENT") if ok { identInt, err := strconv.Atoi(identValue) if err == nil { Ident = strings.Repeat(" ", identInt) } else { Ident = identValue } } for _, r := range Ident { if r == '\n' { continue } if r == '\t' { IdentBytes = append(IdentBytes, ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ') IdentWidth += 8 continue } IdentBytes = append(IdentBytes, byte(r)) IdentWidth++ } } ================================================ FILE: internal/jsonpath/path.go ================================================ package jsonpath import ( "regexp" "strconv" "unicode" ) type state int const ( start state = iota unknown propOrIndex prop index indexEnd number doubleQuote doubleQuoteEscape singleQuote singleQuoteEscape ) func Split(p string) ([]any, bool) { path := make([]any, 0) s := "" state := start for _, ch := range p { switch state { case start: switch { case ch == 'x': state = unknown case ch == '.': state = propOrIndex case ch == '[': state = index default: return path, false } case unknown: switch { case ch == '.': state = prop s = "" case ch == '[': state = index s = "" default: return path, false } case propOrIndex: switch { case isProp(ch): state = prop s = string(ch) case ch == '[': state = index default: return path, false } case prop: switch { case isProp(ch): s += string(ch) case ch == '.': state = prop path = append(path, s) s = "" case ch == '[': state = index path = append(path, s) s = "" default: return path, false } case index: switch { case unicode.IsDigit(ch): state = number s = string(ch) case ch == '"': state = doubleQuote s = "" case ch == '\'': state = singleQuote s = "" default: return path, false } case indexEnd: switch { case ch == ']': state = unknown default: return path, false } case number: switch { case unicode.IsDigit(ch): s += string(ch) case ch == ']': state = unknown n, err := strconv.Atoi(s) if err != nil { return path, false } path = append(path, n) s = "" default: return path, false } case doubleQuote: switch ch { case '"': state = indexEnd path = append(path, s) s = "" case '\\': state = doubleQuoteEscape default: s += string(ch) } case doubleQuoteEscape: switch ch { case '"': state = doubleQuote s += string(ch) default: return path, false } case singleQuote: switch ch { case '\'': state = indexEnd path = append(path, s) s = "" case '\\': state = singleQuoteEscape s += string(ch) default: s += string(ch) } case singleQuoteEscape: switch ch { case '\'': state = singleQuote s += string(ch) default: return path, false } } } if len(s) > 0 { if state == prop { path = append(path, s) } else { return path, false } } return path, true } func isProp(ch rune) bool { return unicode.IsLetter(ch) || unicode.IsDigit(ch) || ch == '_' || ch == '$' } var Identifier = regexp.MustCompile(`^[$a-zA-Z_][$a-zA-Z0-9_]*$`) func Join(path []any) string { s := "" for _, v := range path { switch v := v.(type) { case string: if Identifier.MatchString(v) { s += "." + v } else { s += "[" + strconv.Quote(v) + "]" } case int: s += "[" + strconv.Itoa(v) + "]" } } return s } ================================================ FILE: internal/jsonpath/path_test.go ================================================ package jsonpath_test import ( "testing" "github.com/stretchr/testify/require" "github.com/antonmedv/fx/internal/jsonpath" ) func Test_SplitPath(t *testing.T) { tests := []struct { input string want []any }{ { input: "", want: []any{}, }, { input: ".", want: []any{}, }, { input: "x", want: []any{}, }, { input: ".foo", want: []any{"foo"}, }, { input: "x.foo", want: []any{"foo"}, }, { input: "x[42]", want: []any{42}, }, { input: ".[42]", want: []any{42}, }, { input: ".42", want: []any{"42"}, }, { input: ".физ", want: []any{"физ"}, }, { input: ".foo.bar", want: []any{"foo", "bar"}, }, { input: ".foo[42]", want: []any{"foo", 42}, }, { input: ".foo[42].bar", want: []any{"foo", 42, "bar"}, }, { input: ".foo[1][2]", want: []any{"foo", 1, 2}, }, { input: ".foo[\"bar\"]", want: []any{"foo", "bar"}, }, { input: ".foo[\"bar\\\"\"]", want: []any{"foo", "bar\""}, }, { input: ".foo['bar']['baz\\'']", want: []any{"foo", "bar", "baz\\'"}, }, { input: "[42]", want: []any{42}, }, { input: "[42].foo", want: []any{42, "foo"}, }, } for _, tt := range tests { t.Run(tt.input, func(t *testing.T) { p, ok := jsonpath.Split(tt.input) require.Equal(t, tt.want, p) require.True(t, ok) }) } } func Test_SplitPath_negative(t *testing.T) { tests := []struct { input string }{ { input: "./", }, { input: "x/", }, { input: "1+1", }, { input: "x[42", }, { input: ".i % 2", }, { input: "x[for x]", }, { input: "x['y'.", }, { input: "x[0?", }, { input: "x[\"\\u", }, { input: "x['\\n", }, { input: "x[9999999999999999999999999999999999999]", }, { input: "x[]", }, } for _, tt := range tests { t.Run(tt.input, func(t *testing.T) { p, ok := jsonpath.Split(tt.input) require.False(t, ok, p) }) } } func TestJoin(t *testing.T) { tests := []struct { input []any want string }{ { input: []any{}, want: "", }, { input: []any{"foo"}, want: ".foo", }, { input: []any{"foo", "bar"}, want: ".foo.bar", }, { input: []any{"foo", 42}, want: ".foo[42]", }, { input: []any{"foo", "bar", 42}, want: ".foo.bar[42]", }, { input: []any{"foo", "bar", 42, "baz"}, want: ".foo.bar[42].baz", }, { input: []any{"foo", "bar", 42, "baz", 1}, want: ".foo.bar[42].baz[1]", }, { input: []any{"foo", "bar", 42, "baz", 1, "qux"}, want: ".foo.bar[42].baz[1].qux", }, { input: []any{"foo bar"}, want: "[\"foo bar\"]", }, } for _, tt := range tests { t.Run(tt.want, func(t *testing.T) { require.Equal(t, tt.want, jsonpath.Join(tt.input)) }) } } ================================================ FILE: internal/jsonpath/ref.go ================================================ package jsonpath import ( "net/url" "strings" ) func ParseSchemaRef(ref string) ([]any, bool) { // Must start with '#' if len(ref) == 0 || ref[0] != '#' { return nil, false } // An empty fragment refers to the whole document if ref == "#" { return []any{}, true } // Must be a pointer ("#/...") if !strings.HasPrefix(ref, "#/") { return nil, false } // Split the pointer without the leading '#/' parts := strings.Split(ref[2:], "/") out := make([]any, len(parts)) for i, part := range parts { // JSON Pointer unescaping s := strings.ReplaceAll(strings.ReplaceAll(part, "~1", "/"), "~0", "~") // Percent-unescape unescaped, err := url.PathUnescape(s) if err != nil { return nil, false } out[i] = unescaped } return out, true } ================================================ FILE: internal/jsonpath/ref_test.go ================================================ package jsonpath_test import ( "reflect" "testing" "github.com/antonmedv/fx/internal/jsonpath" ) func TestParseSchemaRef(t *testing.T) { tests := []struct { name string input string want []any wantOk bool }{ { name: "empty fragment", input: "#", want: []any{}, wantOk: true, }, { name: "simple defs", input: "#/$defs/OrganizationConfig/Options", want: []any{"$defs", "OrganizationConfig", "Options"}, wantOk: true, }, { name: "with slash escape", input: "#/path/with~1slash", want: []any{"path", "with/slash"}, wantOk: true, }, { name: "with tilde escape", input: "#/path/with~0tilde", want: []any{"path", "with~tilde"}, wantOk: true, }, { name: "with percent", input: "#/a%20b/c%2Fd", want: []any{"a b", "c/d"}, wantOk: true, }, { name: "invalid no prefix", input: "foo/bar", want: nil, wantOk: false, }, { name: "invalid bad escape", input: "#/bad/%GG", want: nil, wantOk: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, ok := jsonpath.ParseSchemaRef(tt.input) if ok != tt.wantOk { t.Errorf("ParseSchemaRef(%q) ok = %v, want %v", tt.input, ok, tt.wantOk) return } if !reflect.DeepEqual(got, tt.want) { t.Errorf("ParseSchemaRef(%q) = %v, want %v", tt.input, got, tt.want) } }) } } ================================================ FILE: internal/jsonx/delete.go ================================================ package jsonx // DeleteNode removes the node at from the linked structure and returns a node to select next. // It returns (nextToSelect, true) if deletion happened, or (nil, false) if nothing was deleted. // Rules: // - Do nothing if at is nil, points to root (no parent), or is a bracket/closing node (Index == -1). // - If at is a wrap placeholder (Chunk set, Value empty), operate on its parent value. // - Maintain Prev/Next links skipping the deleted range [at..endOf]. // - Clear trailing comma on the previous sibling when deleting the last child before parent's End. // - Decrement parent.Size and reindex subsequent array siblings. // - Choose selection: prefer next; if next is nil or parent.End, prefer prev; else parent. func DeleteNode(at *Node) (*Node, bool) { if at == nil { return nil, false } // Avoid closing bracket nodes (Index == -1 used for brackets) if at.Index == -1 { return nil, false } parent := at.Parent if parent == nil { // avoid deleting root return nil, false } // If current points to a wrap placeholder, move to its parent value if at.Chunk != "" && at.Value == "" && at.Parent != nil { at = at.Parent parent = at.Parent if parent == nil { return nil, false } } // Determine the last node of this item (to skip its subtree or chunks) endOf := at if at.End != nil { endOf = at.End } else if at.ChunkEnd != nil { endOf = at.ChunkEnd } prev := at.Prev next := endOf.Next // If deleting the last child before parent's closing bracket, clear trailing comma on previous sibling isLast := next == parent.End if isLast && prev != nil && prev != parent { prev.Comma = false } // Relink to remove [at..endOf] from the chain if prev != nil { prev.Next = next } if next != nil { next.Prev = prev } // Update parent size and array indices if needed if parent.Size > 0 { parent.Size-- } if parent.Kind == Array { for it := next; it != nil && it != parent.End; { if it.Parent == parent && it.Index >= 0 { it.Index = it.Index - 1 } if it.HasChildren() { it = it.End.Next } else { it = it.Next } } } // Select a sensible node after deletion selectTo := next if selectTo == nil || selectTo == parent.End { if prev != nil && prev != parent { selectTo = prev } else { selectTo = parent } } return selectTo, true } ================================================ FILE: internal/jsonx/delete_test.go ================================================ package jsonx_test import ( "testing" "github.com/stretchr/testify/require" . "github.com/antonmedv/fx/internal/jsonx" ) func TestDeleteNode_ObjectScenarios(t *testing.T) { root, err := Parse([]byte(`{"a":1,"b":2,"c":3}`)) require.NoError(t, err) obj := root require.Equal(t, Object, obj.Kind) // delete middle key b b := obj.FindByPath([]any{"b"}) require.NotNil(t, b) next, ok := DeleteNode(b) require.True(t, ok) require.NotNil(t, next) // after deleting b, next should be c (or its start) c := obj.FindByPath([]any{"c"}) require.NotNil(t, c) // ensure size updated and comma on previous cleared require.Equal(t, 2, obj.Size) // delete last key c -> previous comma should be cleared and selection fallback next2, ok := DeleteNode(c) require.True(t, ok) require.NotNil(t, next2) // now only {"a":1} require.Equal(t, 1, obj.Size) // delete first/only key a -> object empty a := obj.FindByPath([]any{"a"}) require.NotNil(t, a) _, ok = DeleteNode(a) require.True(t, ok) require.Equal(t, 0, obj.Size) } func TestDeleteNode_ArrayScenarios(t *testing.T) { root, err := Parse([]byte(`[10,20,30,40]`)) require.NoError(t, err) arr := root require.Equal(t, Array, arr.Kind) // delete middle index 1 (20) idx1 := arr.FindByPath([]any{1}) require.NotNil(t, idx1) _, ok := DeleteNode(idx1) require.True(t, ok) // remaining should be [10,30,40], indices 0..2 zero := arr.FindByPath([]any{0}) require.NotNil(t, zero) require.Equal(t, "10", zero.Value) one := arr.FindByPath([]any{1}) require.NotNil(t, one) require.Equal(t, "30", one.Value) two := arr.FindByPath([]any{2}) require.NotNil(t, two) require.Equal(t, "40", two.Value) require.Equal(t, 3, arr.Size) // delete last element (now index 2 -> 40) last := arr.FindByPath([]any{2}) require.NotNil(t, last) _, ok = DeleteNode(last) require.True(t, ok) require.Equal(t, 2, arr.Size) // delete first element (10) first := arr.FindByPath([]any{0}) require.NotNil(t, first) _, ok = DeleteNode(first) require.True(t, ok) require.Equal(t, 1, arr.Size) only := arr.FindByPath([]any{0}) require.NotNil(t, only) require.Equal(t, "30", only.Value) // delete the only element _, ok = DeleteNode(only) require.True(t, ok) require.Equal(t, 0, arr.Size) } func TestDeleteNode_EdgeCases(t *testing.T) { root, err := Parse([]byte(`{"k": {"x":1}, "arr":[{"y":2},3]}`)) require.NoError(t, err) // try delete root -> ignored next, ok := DeleteNode(root) require.False(t, ok) require.Nil(t, next) // delete nested object {"y":2} inside arr[0] nested := root.FindByPath([]any{"arr", 0}) require.NotNil(t, nested) _, ok = DeleteNode(nested) require.True(t, ok) // arr should become [3] arr := root.FindByPath([]any{"arr"}) require.NotNil(t, arr) require.Equal(t, 1, arr.Size) el := root.FindByPath([]any{"arr", 0}) require.NotNil(t, el) require.Equal(t, Number, el.Kind) require.Equal(t, "3", el.Value) } ================================================ FILE: internal/jsonx/format_err.go ================================================ package jsonx import ( "fmt" "os" "strings" "unicode/utf8" "github.com/charmbracelet/x/term" "github.com/mattn/go-runewidth" ) func (p *JsonParser) errorSnippet(message string) error { termWidth, _, err := term.GetSize(os.Stdout.Fd()) if err != nil { termWidth = 80 } maxWidth := min(termWidth, 60) maxWidth -= 2 maxWidth = max(maxWidth, 10) // As we already moved end pointer in next(), we need to move it back. p.end -= 1 before, width := p.contextBefore(maxWidth / 2) after, _ := p.contextAfter(maxWidth - width) snippet := " " + before + after snippet += "\n " + strings.Repeat(".", max(0, width-1)) + "^" return fmt.Errorf( "%s on line %d.\n\n%s\n", message, p.realLineNumber, snippet, ) } func (p *JsonParser) contextBefore(maxWidth int) (s string, width int) { pos := p.end + 1 if pos > len(p.data) { pos = len(p.data) } data := p.data[:pos] for len(data) > 0 { r, size := utf8.DecodeLastRune(data) if r == '\n' { break } runeWidth := runewidth.RuneWidth(r) if width+runeWidth > maxWidth { break } width += runeWidth pos -= size data = data[:pos] } s = string(p.data[pos:min(p.end+1, len(p.data))]) return } func (p *JsonParser) contextAfter(maxWidth int) (s string, width int) { pos := p.end + 1 if pos >= len(p.data) { return } data := p.data[pos:] for len(data) > 0 { r, size := utf8.DecodeRune(data) if r == '\n' { break } runeWidth := runewidth.RuneWidth(r) if width+runeWidth > maxWidth { break } width += runeWidth pos += size data = data[size:] } s = string(p.data[p.end+1 : pos]) // +1 to exclude the current character. return } ================================================ FILE: internal/jsonx/json.go ================================================ package jsonx import ( "bytes" "fmt" "io" "strconv" "strings" "unicode" "github.com/antonmedv/fx/internal/utils" ) type JsonParser struct { strict bool rd io.Reader buf []byte data []byte end int eof bool char byte lineNumber int realLineNumber int depth uint8 count int } func Parse(b []byte) (*Node, error) { p := NewJsonParser(bytes.NewReader(b), false) node, err := p.Parse() if err == io.EOF { err = nil } return node, err } func NewJsonParser(rd io.Reader, strict bool) *JsonParser { p := &JsonParser{ strict: strict, rd: rd, buf: make([]byte, 4096), lineNumber: 1, realLineNumber: 1, } p.next() // Should be called here, to support streaming. return p } func (p *JsonParser) Parse() (node *Node, err error) { defer func() { if r := recover(); r != nil { err = p.errorSnippet(fmt.Sprintf("%v", r)) } }() if p.count > 0 { p.skipWhitespace() } if p.eof { return nil, io.EOF } node = p.parseValue(true) p.count++ return } func (p *JsonParser) Recover() *Node { p.eof = false p.depth = 0 start := p.end - 1 for { p.next() if p.eof { break } if p.char == '{' || p.char == '[' { break } } end := p.end - 1 if p.data[end-1] == '\n' { end-- // Trim trailing newline. } start = max(0, min(start, end)) text := string(p.data[start:end]) text = strings.ReplaceAll(text, "\t", " ") text = strings.ReplaceAll(text, "\r", "") lines := strings.Split(text, "\n") textNode := &Node{ Kind: Err, Value: lines[0], Index: -1, LineNumber: p.lineNumberPlusPlus(), } for i := 1; i < len(lines); i++ { textNode.Append(&Node{ Kind: Err, Value: lines[i], Index: -1, Parent: textNode, LineNumber: p.lineNumberPlusPlus(), }) } return textNode } func (p *JsonParser) refill() { n, err := p.rd.Read(p.buf) if err != nil { if err == io.EOF { p.eof = true return } else { panic(err) } } p.data = append(p.data, p.buf[:n]...) } func (p *JsonParser) next() { if p.end >= len(p.data) { p.refill() } if p.eof { p.char = 0 p.end = len(p.data) + 1 return } p.char = p.data[p.end] if p.char == '\n' { p.realLineNumber++ } p.end++ } func (p *JsonParser) back() { p.end-- p.char = p.data[p.end] } func (p *JsonParser) set(pos int) { p.end = pos p.char = p.data[p.end] } func (p *JsonParser) lineNumberPlusPlus() int { n := p.lineNumber p.lineNumber++ return n } func (p *JsonParser) parseValue(root bool) *Node { p.skipWhitespace() var l *Node switch p.char { case '"': l = p.parseString() case '-': l = p.parseMinus() case '0', '1', '2', '3', '4', '5', '6', '7', '8', '9': l = p.parseNumber(p.end - 1) case '{': l = p.parseObject() case '[': l = p.parseArray() case 't': l = p.parseKeyword("true", Bool) case 'f': l = p.parseKeyword("false", Bool) case 'n': l = p.parseNullOrNan() case 'N': l = p.parseNan(p.end - 1) case 'i', 'I': l = p.parseInfinity(p.end - 1) case 'u': if p.strict { panic(fmt.Sprintf("Unexpected character %q", p.char)) } l = p.parseKeyword("undefined", Undefined) default: panic(fmt.Sprintf("Unexpected character %q", p.char)) } // Skip whitespace will block parseValue (with io.Read in refill func), // as soon as we parsed the root value, return and ignore remining whitespaces. if !root { p.skipWhitespace() } return l } func (p *JsonParser) parseString() *Node { return &Node{ Kind: String, Depth: p.depth, Value: p.scanString(), LineNumber: p.lineNumberPlusPlus(), } } func (p *JsonParser) scanString() string { start := p.end - 1 p.next() escaped := false for { if escaped { escaped = false if p.strict { switch p.char { case 'u': var s string for i := 0; i < 4; i++ { p.next() if !utils.IsHexDigit(p.char) { panic(fmt.Sprintf("Invalid Unicode escape sequence '\\u%s%c'", s, p.char)) } s += string(p.char) } _, err := strconv.ParseInt(s, 16, 32) if err != nil { panic(fmt.Sprintf("Invalid Unicode escape sequence '\\u%s'", s)) } case '"', '\\', '/', 'b', 'f', 'n', 'r', 't': default: panic(fmt.Sprintf("Invalid escape sequence '\\%c'", p.char)) } } } else if p.char == '\\' { escaped = true } else if p.char == '"' { break } else if p.char == 0 { panic("Unexpected end of input in string") } else if rune(p.char) > unicode.MaxRune { panic(fmt.Sprintf("Invalid character code point %d in string", p.char)) } p.next() } str := string(p.data[start:p.end]) p.next() return str } func (p *JsonParser) parseMinus() *Node { start := p.end - 1 p.next() switch p.char { case '0', '1', '2', '3', '4', '5', '6', '7', '8', '9': return p.parseNumber(start) } if !p.strict { switch p.char { case 'n', 'N': return p.parseNan(start) case 'i', 'I': return p.parseInfinity(start) } } panic(fmt.Sprintf("Invalid character %q in number", p.char)) } func (p *JsonParser) parseNumber(start int) *Node { num := &Node{ Kind: Number, Depth: p.depth, LineNumber: p.lineNumberPlusPlus(), } // Leading zero if p.char == '0' { p.next() } else { for utils.IsDigit(p.char) { p.next() } } // Decimal portion if p.char == '.' { p.next() if !utils.IsDigit(p.char) { panic(fmt.Sprintf("Invalid character %q in number", p.char)) } for utils.IsDigit(p.char) { p.next() } } // Exponent if p.char == 'e' || p.char == 'E' { p.next() if p.char == '+' || p.char == '-' { p.next() } if !utils.IsDigit(p.char) { panic(fmt.Sprintf("Invalid character %q in number", p.char)) } for utils.IsDigit(p.char) { p.next() } } num.Value = string(p.data[start : p.end-1]) return num } func (p *JsonParser) parseObject() *Node { object := &Node{ Kind: Object, Depth: p.depth, LineNumber: p.lineNumberPlusPlus(), } object.Value = curlyBracketOpen p.next() p.skipWhitespace() // Empty object if p.char == '}' { object.Value = curlyBracketPair p.next() return object } for { // Expecting a key which should be a string if p.char != '"' { panic(fmt.Sprintf("Expected object key to be a string, got %q", p.char)) } keyBytes := p.scanString() p.skipWhitespace() // Expecting colon after key if p.char != ':' { panic(fmt.Sprintf("Expected colon after object key, got %q", p.char)) } p.next() p.depth++ value := p.parseValue(false) value.Key = keyBytes value.Parent = object p.depth-- object.Append(value) object.Size += 1 p.skipWhitespace() commaPos := p.end if p.char == ',' { object.End.Comma = true p.next() p.skipWhitespace() if p.char == '}' { if p.strict { p.set(commaPos) panic("Trailing comma is not allowed in strict mode") } object.End.Comma = false } else { continue } } if p.char == '}' { closeBracket := &Node{ Kind: Object, Depth: p.depth, LineNumber: p.lineNumberPlusPlus(), } closeBracket.Value = curlyBracketClose closeBracket.Parent = object closeBracket.Index = -1 object.Append(closeBracket) p.next() return object } panic(fmt.Sprintf("Unexpected character %q in object", p.char)) } } func (p *JsonParser) parseArray() *Node { arr := &Node{ Kind: Array, Depth: p.depth, LineNumber: p.lineNumberPlusPlus(), } arr.Value = squareBracketOpen p.next() p.skipWhitespace() if p.char == ']' { arr.Value = squareBracketPair p.next() return arr } for i := 0; ; i++ { p.depth++ value := p.parseValue(false) value.Parent = arr arr.Size += 1 value.Index = i p.depth-- arr.Append(value) p.skipWhitespace() commaPos := p.end if p.char == ',' { arr.End.Comma = true p.next() p.skipWhitespace() if p.char == ']' { if p.strict { p.set(commaPos) panic("Trailing comma is not allowed in strict mode") } arr.End.Comma = false } else { continue } } if p.char == ']' { closeBracket := &Node{ Kind: Array, Depth: p.depth, LineNumber: p.lineNumberPlusPlus(), } closeBracket.Value = squareBracketClose closeBracket.Parent = arr closeBracket.Index = -1 arr.Append(closeBracket) p.next() return arr } panic(fmt.Sprintf("Invalid character %q in array", p.char)) } } func (p *JsonParser) parseKeyword(name string, kind Kind) *Node { start := p.end - 1 for i := 1; i < len(name); i++ { p.next() if p.char != name[i] { panic(fmt.Sprintf("Unexpected character %q in keyword", p.char)) } } p.next() if isEndOfValue(p.char) { keyword := &Node{ Kind: kind, Depth: p.depth, Value: string(p.data[start : p.end-1]), LineNumber: p.lineNumberPlusPlus(), } return keyword } panic(fmt.Sprintf("Unexpected character %q in keyword", p.char)) } func (p *JsonParser) parseNullOrNan() *Node { p.next() if p.char == 'u' { p.next() if p.char == 'l' { p.next() if p.char == 'l' { p.next() if isEndOfValue(p.char) { return &Node{ Kind: Null, Depth: p.depth, Value: "null", LineNumber: p.lineNumberPlusPlus(), } } } } } else if p.char == 'a' { p.back() // Put back the 'a'. return p.parseNan(p.end - 1) } panic(fmt.Sprintf("Unexpected character %q", p.char)) } func (p *JsonParser) parseNan(start int) *Node { if p.strict { panic(fmt.Sprintf("Unexpected character %q", p.char)) } p.next() if p.char == 'a' || p.char == 'A' { p.next() if p.char == 'n' || p.char == 'N' { p.next() if isEndOfValue(p.char) { return &Node{ Kind: NaN, Depth: p.depth, Value: string(p.data[start : p.end-1]), LineNumber: p.lineNumberPlusPlus(), } } } } panic(fmt.Sprintf("Unexpected character %q", p.char)) } func (p *JsonParser) parseInfinity(start int) *Node { if p.strict { panic(fmt.Sprintf("Unexpected character %q", p.char)) } p.next() if p.char == 'n' || p.char == 'N' { p.next() if p.char == 'f' || p.char == 'F' { p.next() if isEndOfValue(p.char) { return &Node{ Kind: Infinity, Depth: p.depth, Value: string(p.data[start : p.end-1]), LineNumber: p.lineNumberPlusPlus(), } } if p.char == 'i' { p.next() if p.char == 'n' { p.next() if p.char == 'i' { p.next() if p.char == 't' { p.next() if p.char == 'y' { p.next() if isEndOfValue(p.char) { return &Node{ Kind: Infinity, Depth: p.depth, Value: string(p.data[start : p.end-1]), LineNumber: p.lineNumberPlusPlus(), } } } } } } } } } panic(fmt.Sprintf("Unexpected character %q", p.char)) } func isEndOfValue(ch byte) bool { return isWhitespace(ch) || ch == ',' || ch == '}' || ch == ']' || ch == 0 // 0 is EOF } func isWhitespace(ch byte) bool { return ch == ' ' || ch == '\t' || ch == '\n' || ch == '\r' } func (p *JsonParser) skipWhitespace() { for { switch p.char { case ' ', '\t', '\n', '\r': p.next() case '/': if p.strict { panic("Comments are not allowed in strict mode") } p.skipComment() default: return } } } func (p *JsonParser) skipComment() { p.next() switch p.char { case '/': for p.char != '\n' && p.char != 0 { p.next() } case '*': for { p.next() if p.char == '*' { p.next() if p.char == '/' { p.next() return } } if p.char == 0 { panic("Unexpected end of input in comment") } } default: panic(fmt.Sprintf("Invalid comment: '/%c'", p.char)) } } ================================================ FILE: internal/jsonx/jsonx_test.go ================================================ package jsonx_test import ( "strings" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/antonmedv/fx/internal/jsonx" ) func TestJsonParser_Parse(t *testing.T) { tests := []struct { input string wantKind jsonx.Kind }{ {`"hello"`, jsonx.String}, {`42`, jsonx.Number}, {`-123.45`, jsonx.Number}, {`true`, jsonx.Bool}, {`false`, jsonx.Bool}, {`null`, jsonx.Null}, {`{}`, jsonx.Object}, {`[]`, jsonx.Array}, {`{"key":"value"}`, jsonx.Object}, {`[1, 2, 3]`, jsonx.Array}, {` "test" `, jsonx.String}, {`// comment "test"`, jsonx.String}, {`/* comment */"test"`, jsonx.String}, {`{"a":1,}`, jsonx.Object}, {`[1,2,]`, jsonx.Array}, {`NaN`, jsonx.NaN}, {`-NaN`, jsonx.NaN}, {`nan`, jsonx.NaN}, {`Infinity`, jsonx.Infinity}, {`-Infinity`, jsonx.Infinity}, {`infinity`, jsonx.Infinity}, {`inf`, jsonx.Infinity}, {`INF`, jsonx.Infinity}, {`undefined`, jsonx.Undefined}, {`"\g"`, jsonx.String}, } for _, tt := range tests { t.Run(tt.input, func(t *testing.T) { node, err := jsonx.Parse([]byte(tt.input)) assert.NoError(t, err, "unexpected error for input: %s", tt.input) assert.Equal(t, tt.wantKind, node.Kind, "unexpected kind for input: %s", tt.input) }) } } func TestJsonParser_Parse_error(t *testing.T) { tests := []struct { input string }{ {`"abc`}, {`truth`}, {`1e`}, {`[1, 2`}, {`/* test`}, {`[,]`}, {`{,}`}, {`[1,,]`}, {`{"a":1,,}`}, {`-null`}, {`Null`}, {`-Null`}, } for _, tt := range tests { t.Run(tt.input, func(t *testing.T) { _, err := jsonx.Parse([]byte(tt.input)) assert.Error(t, err, "expected error for input: %s", tt.input) }) } } func TestJsonParser_Parse_strict(t *testing.T) { tests := []struct { input string }{ {`{"a":1,}`}, {`[1,2,]`}, {`NaN`}, {`-NaN`}, {`nan`}, {`Infinity`}, {`-Infinity`}, {`infinity`}, {`inf`}, {`INF`}, {`-null`}, {`Null`}, {`-Null`}, {`/*comment*/ 42`}, {`undefined`}, {`"\g"`}, } for _, tt := range tests { t.Run(tt.input, func(t *testing.T) { parser := jsonx.NewJsonParser(strings.NewReader(tt.input), true) _, err := parser.Parse() assert.Error(t, err, "expected error for input: %s", tt.input) }) } } func TestJsonParser_Recovery(t *testing.T) { brokenJSON := `{ "a": 1 }here goes the text` t.Run("Recover", func(t *testing.T) { p := jsonx.NewJsonParser(strings.NewReader(brokenJSON), false) _, _ = p.Parse() // trigger error node := p.Recover() assert.Equal(t, jsonx.Err, node.Kind, "expected recovery node to be of Kind Err") assert.NotEmpty(t, node.Value, "expected recovery node to contain error snippet") assert.Equal(t, string(node.Value), "here goes the text", "expected recovery node to contain error snippet") }) } func TestJsonParser_NestedStructureVerification(t *testing.T) { input := `{ "user": { "name": "John", "age": 30, "active": true, "contacts": { "email": "john@example.com", "phone": "123456789" }, "roles": ["admin", "editor"] } }` node, err := jsonx.Parse([]byte(input)) assert.NoError(t, err) assert.Equal(t, jsonx.Object, node.Kind) // user object user := node.FindByPath([]any{"user"}) assert.NotNil(t, user) assert.Equal(t, jsonx.Object, user.Kind) // user.name name := node.FindByPath([]any{"user", "name"}) assert.NotNil(t, name) assert.Equal(t, jsonx.String, name.Kind) assert.Equal(t, `"John"`, string(name.Value)) // user.age age := node.FindByPath([]any{"user", "age"}) assert.NotNil(t, age) assert.Equal(t, jsonx.Number, age.Kind) assert.Equal(t, "30", string(age.Value)) // user.active active := node.FindByPath([]any{"user", "active"}) assert.NotNil(t, active) assert.Equal(t, jsonx.Bool, active.Kind) assert.Equal(t, "true", string(active.Value)) // user.contacts.email email := node.FindByPath([]any{"user", "contacts", "email"}) assert.NotNil(t, email) assert.Equal(t, jsonx.String, email.Kind) assert.Equal(t, `"john@example.com"`, string(email.Value)) // user.roles[1] role := node.FindByPath([]any{"user", "roles", 1}) assert.NotNil(t, role) assert.Equal(t, jsonx.String, role.Kind) assert.Equal(t, `"editor"`, string(role.Value)) } func TestJsonParser_FindByPathWithCollapsedNodes(t *testing.T) { t.Run("collapsed array access", func(t *testing.T) { node, err := jsonx.Parse([]byte(`{ "items": [1, 2, 3, 4, 5] }`)) require.NoError(t, err) node.CollapseRecursively() items := node.FindByPath([]any{"items"}) require.NotNil(t, items) element := node.FindByPath([]any{"items", 2}) require.NotNil(t, element) require.Equal(t, jsonx.Number, element.Kind) require.Equal(t, "3", element.Value) }) t.Run("collapsed object access", func(t *testing.T) { node, err := jsonx.Parse([]byte(`{ "user": { "settings": { "theme": "dark", "notifications": true } } }`)) require.NoError(t, err) node.CollapseRecursively() settings := node.FindByPath([]any{"user", "settings"}) require.NotNil(t, settings) theme := node.FindByPath([]any{"user", "settings", "theme"}) require.NotNil(t, theme) require.Equal(t, jsonx.String, theme.Kind) require.Equal(t, `"dark"`, string(theme.Value)) }) t.Run("nested collapsed structures", func(t *testing.T) { node, err := jsonx.Parse([]byte(`{ "data": { "users": [ {"id": 1, "name": "John"}, {"id": 2, "name": "Jane"} ] } }`)) require.NoError(t, err) node.CollapseRecursively() users := node.FindByPath([]any{"data", "users"}) require.NotNil(t, users) userName := node.FindByPath([]any{"data", "users", 1, "name"}) require.NotNil(t, userName) require.Equal(t, jsonx.String, userName.Kind) require.Equal(t, `"Jane"`, string(userName.Value)) }) t.Run("nested collapsed structures with arrays", func(t *testing.T) { node, err := jsonx.Parse([]byte(`{ "data": [ { "first": [ "tmp", { "foo": [ 1, 2, true ] } ] }, { "second": [] } ] }`)) require.NoError(t, err) node.CollapseRecursively() value := node.FindByPath([]any{"data", 0, "first", 1, "foo", 2}) require.NotNil(t, value) require.Equal(t, jsonx.Bool, value.Kind) }) } ================================================ FILE: internal/jsonx/line.go ================================================ package jsonx import ( "bufio" "io" "strconv" "strings" ) type LineParser struct { buf *bufio.Reader eof error lineNumber int } func NewLineParser(in io.Reader) *LineParser { p := &LineParser{ buf: bufio.NewReader(in), lineNumber: 1, } return p } func (p *LineParser) Parse() (*Node, error) { if p.eof != nil { return nil, p.eof } b, err := p.buf.ReadBytes('\n') if err != nil { if err == io.EOF { p.eof = err } else { return nil, err } } if len(b) == 0 { return nil, err } s := strings.TrimRight(string(b), "\r\n") quoted := strconv.Quote(s) node := &Node{ Kind: String, Value: quoted, LineNumber: p.lineNumber, Depth: 0, } p.lineNumber++ return node, nil } func (p *LineParser) Recover() *Node { return nil } ================================================ FILE: internal/jsonx/node.go ================================================ package jsonx import ( "strconv" "github.com/antonmedv/fx/internal/jsonpath" ) type Kind byte const ( Err Kind = iota Null Bool Number String Object Array NaN Infinity Undefined ) type Node struct { Prev, Next, End *Node Parent *Node Collapsed *Node Depth uint8 Kind Kind Key string Value string Size int Chunk string ChunkEnd *Node Comma bool Index int LineNumber int } // Append ands a node as a child to the current node (body of {...} or [...]). func (n *Node) Append(child *Node) { if n.End == nil { n.End = n } n.End.Next = child child.Prev = n.End if child.End == nil { n.End = child } else { n.End = child.End } } // Adjacent adds a node as a sibling to the current node ({}{}{} or [][][]). func (n *Node) Adjacent(child *Node) { end := n.End if end == nil { end = n } end.Next = child child.Prev = end if n.IsCollapsed() { // Also attach to collapsed node. n.Next = child child.Prev = n } } func (n *Node) insertChunk(chunk *Node) { if n.ChunkEnd == nil { n.insertAfter(chunk) } else { n.ChunkEnd.insertAfter(chunk) } n.ChunkEnd = chunk } func (n *Node) insertAfter(child *Node) { if n.Next == nil { n.Next = child child.Prev = n } else { old := n.Next n.Next = child child.Prev = n child.Next = old old.Prev = child } } func (n *Node) dropChunks() { if n.ChunkEnd == nil { return } n.Chunk = "" n.Next = n.ChunkEnd.Next if n.Next != nil { n.Next.Prev = n } n.ChunkEnd = nil } func (n *Node) HasChildren() bool { return n.End != nil } func (n *Node) Root() *Node { parent := n.Parent for parent != nil { n = parent parent = n.Parent } return n } func (n *Node) IsWrap() bool { return n.Value == "" && n.Chunk != "" } func (n *Node) IsCollapsed() bool { return n.Collapsed != nil } func (n *Node) Collapse() *Node { if n.End != nil && !n.IsCollapsed() { n.Collapsed = n.Next n.Next = n.End.Next if n.Next != nil { n.Next.Prev = n } } return n } func (n *Node) CollapseRecursively() { var at *Node if n.IsCollapsed() { at = n.Collapsed } else { at = n.Next } for at != nil && at != n.End { if at.HasChildren() { at.CollapseRecursively() at.Collapse() } at = at.Next } } func (n *Node) Expand() { if n.IsCollapsed() { if n.Next != nil { n.Next.Prev = n.End } n.Next = n.Collapsed n.Collapsed = nil } } func (n *Node) ExpandRecursively(level, maxLevel int) { if level >= maxLevel { return } if n.IsCollapsed() { n.Expand() } it := n.Next for it != nil && it != n.End { if it.HasChildren() { it.ExpandRecursively(level+1, maxLevel) it = it.End.Next } else { it = it.Next } } } func (n *Node) FindByPath(path []any) *Node { it := n for _, part := range path { if it == nil { return nil } switch part := part.(type) { case string: it = it.findChildByKey(part) case int: it = it.findChildByIndex(part) } } return it } func (n *Node) findChildByKey(key string) *Node { var it *Node if n.Collapsed != nil { it = n.Collapsed } else { it = n.Next } for it != nil && it != n.End { if it.Key != "" { k, err := strconv.Unquote(it.Key) if err != nil { continue } if k == key { return it } } if it.ChunkEnd != nil { it = it.ChunkEnd.Next } else if it.End != nil { it = it.End.Next } else { it = it.Next } } return nil } func (n *Node) findChildByIndex(index int) *Node { var at *Node if n.Collapsed != nil { at = n.Collapsed } else { at = n.Next } for at != nil && at != n.End { if at.Index == index { return at } if at.End != nil { at = at.End.Next } else { at = at.Next } } return nil } func (n *Node) FindNextNonErr() *Node { it := n for it != nil && it.Kind == Err { it = it.Next } return it } func (n *Node) Children() ([]string, []*Node) { if !n.HasChildren() { return nil, nil } var paths []string var nodes []*Node var it *Node if n.IsCollapsed() { it = n.Collapsed } else { it = n.Next } for it != nil && it != n.End { if it.Key != "" { key := it.Key unquoted, err := strconv.Unquote(key) if err == nil { key = unquoted } paths = append(paths, key) nodes = append(nodes, it) } if it.HasChildren() { it = it.End.Next } else { it = it.Next } } return paths, nodes } func (n *Node) Bottom() *Node { it := n for it.Next != nil { if it.End != nil { it = it.End } else { it = it.Next } } return it } func (n *Node) Paths(paths *[]string, nodes *[]*Node) { joinPath := func(prefix string, n *Node) string { var path string if n.Key != "" { quoted := n.Key unquoted, err := strconv.Unquote(quoted) if err == nil && jsonpath.Identifier.MatchString(unquoted) { path = prefix + "." + unquoted } else { path = prefix + "[" + quoted + "]" } } else if n.Index >= 0 { path = prefix + "[" + strconv.Itoa(n.Index) + "]" } return path } type item struct { node *Node path string } var queue []item queue = append(queue, item{node: n, path: ""}) for len(queue) > 0 { curr := queue[0] queue = queue[1:] it := curr.node prefix := curr.path if it.IsCollapsed() { it = it.Collapsed } else { it = it.Next } for it != nil && it != curr.node.End { path := joinPath(prefix, it) if path != "" { if len(*paths) == cap(*paths) { return } *paths = append(*paths, path) *nodes = append(*nodes, it) } if it.HasChildren() { queue = append(queue, item{node: it, path: path}) it = it.End.Next } else { it = it.Next } } } } func (n *Node) ForEach(cb func(*Node)) { it := n.Next for it != nil && it != n.End { cb(it) if it.HasChildren() { it = it.End.Next } else { it = it.Next } } } ================================================ FILE: internal/jsonx/node_test.go ================================================ package jsonx import ( "strconv" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestNode_children(t *testing.T) { n, err := Parse([]byte(`{"a": 1, "b": {"f": 2}, "c": [3, 4]}`)) require.NoError(t, err) paths, _ := n.Children() assert.Equal(t, []string{"a", "b", "c"}, paths) } func TestNode_expandRecursively(t *testing.T) { n, err := Parse([]byte(`{"a": {"b": {"c": 1}}}`)) require.NoError(t, err) n.CollapseRecursively() n.ExpandRecursively(0, 3) assert.Equal(t, `"c"`, n.Next.Next.Next.Key) } func TestNode_Paths(t *testing.T) { n, err := Parse([]byte(`{"a": 1, "b": {"f": 2}, "c": [3, {"d": 4}]}`)) require.NoError(t, err) paths := make([]string, 0, 10) nodes := make([]*Node, 0, 10) n.Paths(&paths, &nodes) assert.Equal(t, []string{ ".a", ".b", ".c", ".b.f", ".c[0]", ".c[1]", ".c[1].d", }, paths) } func TestNode_Paths_Collapsed(t *testing.T) { n, err := Parse([]byte(`{"a": 1, "b": {"f": 2}, "c": [3, {"d": 4}]}`)) require.NoError(t, err) n.CollapseRecursively() paths := make([]string, 0, 10) nodes := make([]*Node, 0, 10) n.Paths(&paths, &nodes) assert.Equal(t, []string{ ".a", ".b", ".c", ".b.f", ".c[0]", ".c[1]", ".c[1].d", }, paths) } func TestNode_ForEach(t *testing.T) { n, err := Parse([]byte(`{"a": 1, "b": 2, "c": 3}`)) require.NoError(t, err) var keys []string n.ForEach(func(node *Node) { if k, err := strconv.Unquote(node.Key); err == nil { keys = append(keys, k) } }) assert.Equal(t, []string{"a", "b", "c"}, keys) } func TestNode_ForEach_Empty(t *testing.T) { n, err := Parse([]byte(`{}`)) require.NoError(t, err) called := false n.ForEach(func(node *Node) { called = true }) assert.False(t, called) } func TestNode_ForEach_SkipsNested(t *testing.T) { n, err := Parse([]byte(`{"a": {"b": 1}, "c": [2, {"d": 3}]}`)) require.NoError(t, err) var keys []string n.ForEach(func(node *Node) { if k, err := strconv.Unquote(node.Key); err == nil { keys = append(keys, k) } }) assert.Equal(t, []string{"a", "c"}, keys) } ================================================ FILE: internal/jsonx/string.go ================================================ package jsonx import ( "strings" ) const ( curlyBracketOpen = "{" curlyBracketClose = "}" curlyBracketPair = "{}" squareBracketOpen = "[" squareBracketClose = "]" squareBracketPair = "[]" ) func (n *Node) String() string { var out strings.Builder it := n for it != nil { if it.Key != "" { out.WriteString(it.Key) out.WriteByte(':') } if it.Value != "" { out.WriteString(it.Value) } if it.Comma { out.WriteByte(',') } if it.IsCollapsed() { it = it.Collapsed } else { it = it.Next } } return out.String() } ================================================ FILE: internal/jsonx/to_value.go ================================================ package jsonx import ( "fmt" "math" "math/big" "strconv" "github.com/dop251/goja" "github.com/antonmedv/fx/internal/utils" ) func (n *Node) ToValue(vm *goja.Runtime) goja.Value { switch n.Kind { case Null: return goja.Null() case Bool: if n.Value == "true" { return vm.ToValue(true) } else { return vm.ToValue(false) } case Number: i, ok := ParseNumber(n.Value) if ok { return vm.ToValue(i) } f, err := strconv.ParseFloat(n.Value, 64) if err == nil { return vm.ToValue(f) } panic(err) case String: unquoted, err := utils.Unquote(n.Value) if err != nil { panic(err) } return vm.ToValue(unquoted) case Object: obj := vm.NewObject() if n.HasChildren() { it := n if it.IsCollapsed() { it = it.Collapsed } else { it = it.Next } for it != nil && it != n.End { unquotedKey, err := utils.Unquote(it.Key) if err != nil { panic(err) } err = obj.Set(unquotedKey, it.ToValue(vm)) if err != nil { panic(err) } if it.HasChildren() { it = it.End.Next } else { it = it.Next } } } return obj case Array: var arr []any if n.HasChildren() { it := n if it.IsCollapsed() { it = it.Collapsed } else { it = it.Next } for it != nil && it != n.End { arr = append(arr, it.ToValue(vm)) if it.HasChildren() { it = it.End.Next } else { it = it.Next } } } return vm.NewArray(arr...) case NaN: return vm.ToValue(math.NaN()) case Infinity: if n.Value[0] == '-' { return vm.ToValue(math.Inf(-1)) } return vm.ToValue(math.Inf(1)) case Undefined: return goja.Undefined() } panic(fmt.Sprintf("unsupported node kind %d", n.Kind)) } // maxSafeInt is 2^53 - 1, the largest integer JS can represent exactly. const maxSafeInt = 1<<53 - 1 // minSafeInt is -(2^53 - 1). const minSafeInt = -maxSafeInt // ParseNumber parses a number from a string as int64 or *big.Int. func ParseNumber(s string) (interface{}, bool) { bi := new(big.Int) if _, ok := bi.SetString(s, 10); !ok { return nil, false } // Quickly reject values whose bit-length exceeds 54 (i.e. >= 2^53). // big.Int.BitLen returns the length of the absolute value in bits. if bi.BitLen() <= 53 { // Safe to convert to int64 and check full range. v := bi.Int64() if v >= minSafeInt && v <= maxSafeInt { return int(v), true } } return bi, true } ================================================ FILE: internal/jsonx/wrap.go ================================================ package jsonx import ( "unicode/utf8" "github.com/mattn/go-runewidth" "github.com/antonmedv/fx/internal/ident" ) func DropWrapAll(n *Node) { for n != nil { if n.Kind == String || n.Kind == Err { n.dropChunks() } if n.IsCollapsed() { n = n.Collapsed } else { n = n.Next } } } func Wrap(n *Node, termWidth int) { if termWidth <= 0 { return } for n != nil { if n.Kind == String || n.Kind == Err { n.dropChunks() lines, count := doWrap(n, termWidth) if count > 1 { n.Chunk = lines[0] for i := 1; i < count; i++ { child := &Node{ Kind: n.Kind, Parent: n, Depth: n.Depth, Chunk: lines[i], } if n.Comma && i == count-1 { child.Comma = true } n.insertChunk(child) } } } if n.IsCollapsed() { n = n.Collapsed } else { n = n.Next } } } func doWrap(n *Node, termWidth int) ([]string, int) { lines := make([]string, 0, 1) width := int(n.Depth) * ident.IdentWidth if n.Key != "" { for _, ch := range n.Key { width += runewidth.RuneWidth(ch) } width += 2 // for ": " } linesCount := 0 start, end := 0, 0 b := []byte(n.Value) for len(b) > 0 { r, size := utf8.DecodeRune(b) w := runewidth.RuneWidth(r) if width+w > termWidth { lines = append(lines, n.Value[start:end]) start = end width = int(n.Depth) * 2 linesCount++ } width += w end += size b = b[size:] } if start < end { lines = append(lines, n.Value[start:]) linesCount++ } return lines, linesCount } ================================================ FILE: internal/pretty/inlineable.go ================================================ package pretty import ( "github.com/antonmedv/fx/internal/jsonx" ) func isInlineable(n *jsonx.Node) bool { if n.Kind == jsonx.Array && len(n.Key) > 0 { if isSimpleNumbersArray(n) { return true } if isSingleElementArray(n) { return true } } return false } func isSingleElementArray(n *jsonx.Node) bool { if n.Kind == jsonx.Array && n.Size == 1 { it := n.Next if it != nil { if it.Kind == jsonx.Null || it.Kind == jsonx.Bool || it.Kind == jsonx.Number { return true } if it.Kind == jsonx.String && len(it.Value) <= 80 { return true } } } return false } func isSimpleNumbersArray(n *jsonx.Node) bool { if n.Kind == jsonx.Array { isAllNumbers := true count := 0 n.ForEach(func(child *jsonx.Node) { count++ if child.Kind != jsonx.Number { isAllNumbers = false } }) return isAllNumbers && count > 0 } return false } func isSimpleObject(n *jsonx.Node) bool { if n.Kind == jsonx.Object { // Special case for empty objects if n.Size == 0 { return true } // Special case: exactly one key with string value and len(key+value) <= 80 chars if n.Size == 1 { var hasOneStringValue bool var keyLength, valueLength int n.ForEach(func(child *jsonx.Node) { keyLength = len(child.Key) if child.Kind == jsonx.String { valueLength = len(child.Value) hasOneStringValue = true } }) if hasOneStringValue && keyLength+valueLength <= 80 { return true } } // Original implementation isSimple := true count := 0 numStrings := 0 numOther := 0 n.ForEach(func(child *jsonx.Node) { count++ if len(child.Key) > 10 { isSimple = false return } if child.Kind == jsonx.String { numStrings++ if len(child.Value) > 20 { isSimple = false } } else if child.Kind == jsonx.Number || child.Kind == jsonx.Bool || child.Kind == jsonx.Null { numOther++ } else { isSimple = false } }) // Apply limits based on the types of values present if numStrings > 2 || numOther > 3 { isSimple = false } return isSimple } return false } func isNestedArrays(n *jsonx.Node) bool { if n.Kind != jsonx.Array || n.Size == 0 { return false } isValid := true n.ForEach(func(child *jsonx.Node) { if child.Kind != jsonx.Array { isValid = false return } child.ForEach(func(innerChild *jsonx.Node) { if innerChild.Kind != jsonx.Number { isValid = false } }) }) return isValid } func isArrayOfSimpleObject(n *jsonx.Node) bool { if n.Kind != jsonx.Array || n.Size == 0 { return false } isValid := true n.ForEach(func(child *jsonx.Node) { if !isSimpleObject(child) { isValid = false } }) return isValid } ================================================ FILE: internal/pretty/inlineable_test.go ================================================ package pretty_test import ( "strings" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/antonmedv/fx/internal/jsonx" "github.com/antonmedv/fx/internal/pretty" ) func TestIsInlineable(t *testing.T) { tests := []struct { name string json string expected bool }{ { name: "simple array with numbers", json: `{"key": [1, 2, 3]}`, expected: true, }, { name: "array with non-number elements", json: `{"key": [1, "string", true]}`, expected: false, }, { name: "empty array", json: `{"key": []}`, expected: true, }, { name: "array without key", json: `[1, 2, 3]`, expected: false, }, { name: "simple object with number values", json: `{"key": {"a": 1, "b": 2, "c": 3}}`, expected: false, }, { name: "simple object with boolean values", json: `{"key": {"a": true, "b": false, "c": true}}`, expected: false, }, { name: "simple object with short string values", json: `{"key": {"a": "short", "b": "string"}}`, expected: false, }, { name: "object with long key", json: `{"key": {"thisIsAVeryLongKey": 1, "b": 2}}`, expected: false, }, { name: "object with mixed value types", json: `{"key": {"a": 1, "b": "string"}}`, expected: false, }, { name: "object with long string value", json: `{"key": {"a": "this is a very long string that exceeds twenty characters"}}`, expected: false, }, { name: "object with too many string values", json: `{"key": {"a": "string1", "b": "string2", "c": "string3"}}`, expected: false, }, { name: "object with too many number values", json: `{"key": {"a": 1, "b": 2, "c": 3, "d": 4}}`, expected: false, }, { name: "object without key", json: `{"a": 1, "b": 2}`, expected: false, }, { name: "empty object", json: `{"key": {}}`, expected: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { node, err := jsonx.Parse([]byte(tt.json)) require.NoError(t, err) var testNode *jsonx.Node if strings.Contains(tt.json, `"key":`) { testNode = node.FindByPath([]any{"key"}) require.NotNil(t, testNode, "Could not find node with key 'key'") } else { testNode = node } output := pretty.Print(testNode, true) lineCount := strings.Count(output, "\n") isInlined := lineCount == 1 assert.Equal(t, tt.expected, isInlined, "Expected isInlineable to be %v for %s, but got %v\nOutput:\n%s", tt.expected, tt.json, isInlined, output) }) } } func TestIsNestedArrays(t *testing.T) { tests := []struct { name string json string expected bool }{ // Valid tables { name: "valid table - array of arrays with numbers of same size", json: `[[1, 2, 3], [4, 5, 6], [7, 8, 9]]`, expected: true, }, { name: "valid table - array of arrays with single number", json: `[[1], [2], [3]]`, expected: true, }, { name: "not a table - table with key", json: `{"table": [[1, 2], [3, 4]]}`, expected: false, }, { name: "valid table with many rows", json: `[[1, 2], [3, 4], [5, 6], [7, 8], [9, 10]]`, expected: true, }, { name: "valid table with only one inner array", json: `[[1, 2, 3]]`, expected: true, }, { name: "valid table with multiple arrays of different sizes", json: `[[1, 2, 3, 4], [5, 6], [7, 8, 9], [10]]`, expected: true, }, // Invalid tables { name: "not a table - array with non-array elements", json: `[1, 2, 3]`, expected: false, }, { name: "not a table - array of arrays with non-number elements", json: `[[1, 2], ["a", "b"]]`, expected: false, }, { name: "valid table - array of arrays with different sizes", json: `[[1, 2, 3], [4, 5]]`, expected: true, }, { name: "not a table - empty array", json: `[]`, expected: false, }, { name: "not a table - array with mixed content", json: `[[1, 2], 3, [4, 5]]`, expected: false, }, { name: "not a table - array of arrays with boolean values", json: `[[true, false], [false, true]]`, expected: false, }, { name: "not a table - array of arrays with string values", json: `[["a", "b"], ["c", "d"]]`, expected: false, }, { name: "not a table - array of arrays with null values", json: `[[null, null], [null, null]]`, expected: false, }, { name: "not a table - array of arrays with object values", json: `[[{"a": 1}, {"b": 2}], [{"c": 3}, {"d": 4}]]`, expected: false, }, { name: "not a table - array of arrays with array values", json: `[[[1], [2]], [[3], [4]]]`, expected: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { node, err := jsonx.Parse([]byte(tt.json)) require.NoError(t, err) var testNode *jsonx.Node if strings.Contains(tt.json, `"table":`) { testNode = node.FindByPath([]any{"table"}) require.NotNil(t, testNode, "Could not find node with key 'table'") } else { testNode = node } output := pretty.Print(testNode, true) // Check if the output has the characteristics of a table format // For a table, each inner array should be on its own line in a tabular format lines := strings.Split(output, "\n") // A table should have at least 3 lines (opening bracket, content, closing bracket) // and the content lines should be formatted in a specific way isTable := false if len(lines) >= 3 { // Check if the first line contains the opening bracket if strings.Contains(lines[0], "[") { // Check if the last non-empty line contains the closing bracket lastNonEmptyIndex := len(lines) - 1 for lastNonEmptyIndex >= 0 && lines[lastNonEmptyIndex] == "" { lastNonEmptyIndex-- } if lastNonEmptyIndex >= 0 && strings.Contains(lines[lastNonEmptyIndex], "]") { // Check if the middle lines have a consistent format // In a table, each line should start with the same indentation and contain numbers isTable = true for i := 1; i < lastNonEmptyIndex; i++ { if lines[i] == "" { continue } // Each line in a table should contain numbers and be properly indented if !strings.Contains(lines[i], "[") || !strings.Contains(lines[i], "]") { isTable = false break } } } } } assert.Equal(t, tt.expected, isTable, "Expected isNestedArrays to be %v for %s, but got %v\nOutput:\n%s", tt.expected, tt.json, isTable, output) }) } } func TestIsArrayOfSimpleObject(t *testing.T) { tests := []struct { name string json string expected bool }{ // Valid arrays of simple objects { name: "array of simple objects with number values", json: `[{"a": 1, "b": 2}, {"a": 3, "b": 4}, {"a": 5, "b": 6}]`, expected: true, }, { name: "array of simple objects with boolean values", json: `[{"a": true, "b": false}, {"a": false, "b": true}]`, expected: true, }, { name: "array of simple objects with short string values", json: `[{"a": "short", "b": "text"}, {"a": "another", "b": "value"}]`, expected: true, }, { name: "array with single simple object", json: `[{"a": 1, "b": 2, "c": 3}]`, expected: true, }, { name: "array of simple objects with different keys but same value types", json: `[{"a": 1, "b": 2}, {"c": 3, "d": 4}]`, expected: true, }, { name: "object containing array of simple objects", json: `{"data": [{"a": 1, "b": 2}, {"a": 3, "b": 4}]}`, expected: false, }, { name: "empty array", json: `[]`, expected: false, }, { name: "array of non-objects", json: `[1, 2, 3]`, expected: false, }, { name: "array of mixed types", json: `[{"a": 1}, 2, {"b": 3}]`, expected: false, }, { name: "array of objects with long keys", json: `[{"veryLongKey": 1, "b": 2}, {"veryLongKey": 3, "b": 4}]`, expected: false, }, { name: "array - array of objects with mixed value types", json: `[{"a": 1, "b": "string"}, {"a": 2, "b": "text"}]`, expected: true, }, { name: "array of objects with long string values", json: `[{"a": "this is a very long string that exceeds twenty characters"}, {"a": "short"}]`, expected: true, }, { name: "array of objects with too many string values", json: `[{"a": "string1", "b": "string2", "c": "string3"}, {"a": "text1", "b": "text2", "c": "text3"}]`, expected: false, }, { name: "array of objects with too many number values", json: `[{"a": 1, "b": 2, "c": 3, "d": 4}, {"a": 5, "b": 6, "c": 7, "d": 8}]`, expected: false, }, { name: "array of objects with nested objects", json: `[{"a": {"nested": 1}}, {"a": {"nested": 2}}]`, expected: false, }, { name: "array of objects with arrays", json: `[{"a": [1, 2]}, {"a": [3, 4]}]`, expected: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { node, err := jsonx.Parse([]byte(tt.json)) require.NoError(t, err) var testNode *jsonx.Node if strings.Contains(tt.json, `"data":`) { testNode = node.FindByPath([]any{"data"}) require.NotNil(t, testNode, "Could not find node with key 'data'") } else { testNode = node } output := pretty.Print(testNode, true) // Check if the output has the characteristics of a table format // For a table, each object should be on its own line in a tabular format lines := strings.Split(output, "\n") // A table should have at least 3 lines (opening bracket, content, closing bracket) // and the content lines should be formatted in a specific way isTable := false if len(lines) >= 3 { // Check if the first line contains the opening bracket if strings.Contains(lines[0], "[") { // Check if the last non-empty line contains the closing bracket lastNonEmptyIndex := len(lines) - 1 for lastNonEmptyIndex >= 0 && lines[lastNonEmptyIndex] == "" { lastNonEmptyIndex-- } if lastNonEmptyIndex >= 0 && strings.Contains(lines[lastNonEmptyIndex], "]") { // Check if the middle lines have a consistent format // In a table, each line should start with the same indentation and contain objects isTable = true for i := 1; i < lastNonEmptyIndex; i++ { if lines[i] == "" { continue } // Each line in a table should contain objects and be properly indented if !strings.Contains(lines[i], "{") || !strings.Contains(lines[i], "}") { isTable = false break } } } } } assert.Equal(t, tt.expected, isTable, "Expected isArrayOfSimpleObject to be %v for %s, but got %v\nOutput:\n%s", tt.expected, tt.json, isTable, output) }) } } ================================================ FILE: internal/pretty/pretty_print.go ================================================ package pretty import ( "strings" "github.com/antonmedv/fx/internal/ident" "github.com/antonmedv/fx/internal/jsonx" "github.com/antonmedv/fx/internal/theme" ) // Print pretty prints a Node. Node must be the top (head), // as everything will be printed. func Print(n *jsonx.Node, withInline bool) string { var out strings.Builder it := n for it != nil { if withInline { if isNestedArrays(it) { it = table(&out, it) continue } if isArrayOfSimpleObject(it) { it = table(&out, it) continue } if isInlineable(it) { it = inline(&out, it) continue } } printIdent(&out, it) printKey(&out, it) printValue(&out, it) it = next(it) if it != nil { out.WriteByte('\n') } } return out.String() } func table(out *strings.Builder, n *jsonx.Node) *jsonx.Node { printIdent(out, n) printKey(out, n) printValue(out, n) out.WriteByte('\n') it := next(n) end := n.End for it != nil && it != end { it = inline(out, it) } printIdent(out, end) printValue(out, end) it = next(it) if it != nil { out.WriteByte('\n') } return it } func inline(out *strings.Builder, n *jsonx.Node) *jsonx.Node { printIdent(out, n) printSpace := false it := n end := afterEnd(n) for it != nil && it != end { if printSpace { out.WriteString(" ") } else { printSpace = true } printKey(out, it) printValue(out, it) it = next(it) } out.WriteByte('\n') return it } func printIdent(out *strings.Builder, n *jsonx.Node) { for i := 0; i < int(n.Depth); i++ { out.WriteString(ident.Ident) } } func printKey(out *strings.Builder, n *jsonx.Node) { if n.Key != "" { out.WriteString(theme.CurrentTheme.Key(n.Key)) out.WriteString(theme.Colon) } } func printValue(out *strings.Builder, n *jsonx.Node) { if n.Value != "" { out.WriteString(theme.Value(n.Kind)(n.Value)) } if n.Comma { out.WriteString(theme.Comma) } } func next(n *jsonx.Node) *jsonx.Node { if n.IsCollapsed() { return n.Collapsed } else { return n.Next } } func afterEnd(n *jsonx.Node) *jsonx.Node { if n.End != nil { return n.End.Next } return n.Next } ================================================ FILE: internal/pretty/pretty_print_test.go ================================================ package pretty_test import ( "regexp" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/antonmedv/fx/internal/jsonx" "github.com/antonmedv/fx/internal/pretty" ) func stripEscapeSequences(s string) string { re := regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]`) return re.ReplaceAllString(s, "") } const ( yes byte = iota no both ) func TestPrettyPrint(t *testing.T) { tests := []struct { name string json string expected string inline byte }{ { name: "standalone null with inline", json: `null`, expected: `null`, inline: both, }, { name: "standalone true with inline", json: `true`, expected: `true`, inline: both, }, { name: "standalone false with inline", json: `false`, expected: `false`, inline: both, }, { name: "array with empty object and empty array with inline", json: `[{}, []]`, expected: `[ {}, [] ]`, inline: both, }, { name: "simple object with inline", json: `{"name":"John","age":30,"city":"New York"}`, expected: `{ "name": "John", "age": 30, "city": "New York" }`, inline: both, }, { name: "nested object without inline", json: `{"person":{"name":"John","age":30,"address":{"city":"New York","zip":"10001"}}}`, expected: `{ "person": { "name": "John", "age": 30, "address": { "city": "New York", "zip": "10001" } } }`, inline: both, }, { name: "array of numbers with inline", json: `{"numbers":[1,2,3,4,5]}`, expected: `{ "numbers": [ 1, 2, 3, 4, 5 ] }`, inline: yes, }, { name: "array of numbers without inline", json: `{"numbers":[1,2,3,4,5]}`, expected: `{ "numbers": [ 1, 2, 3, 4, 5 ] }`, inline: no, }, { name: "array of objects with inline", json: `{"people":[{"name":"John","age":30},{"name":"Jane","age":25}]}`, expected: `{ "people": [ { "name": "John", "age": 30 }, { "name": "Jane", "age": 25 } ] }`, inline: yes, }, { name: "array of objects without inline", json: `{"people":[{"name":"John","age":30},{"name":"Jane","age":25}]}`, expected: `{ "people": [ { "name": "John", "age": 30 }, { "name": "Jane", "age": 25 } ] }`, inline: no, }, { name: "empty object with inline", json: `{}`, expected: `{}`, inline: both, }, { name: "empty array with inline", json: `[]`, expected: `[]`, inline: both, }, { name: "null value with inline", json: `{"value":null}`, expected: `{ "value": null }`, inline: both, }, { name: "boolean values with inline", json: `{"active":true,"verified":false}`, expected: `{ "active": true, "verified": false }`, inline: both, }, { name: "string with special characters without inline", json: `{"message":"Hello, \"World\"!\nNew line\tTab"}`, expected: `{ "message": "Hello, \"World\"!\nNew line\tTab" }`, inline: both, }, { name: "deeply nested structure with inline", json: `{"level1":{"level2":{"level3":{"level4":{"level5":"deep value"}}}}}`, expected: `{ "level1": { "level2": { "level3": { "level4": { "level5": "deep value" } } } } }`, inline: both, }, { name: "mixed array elements with inline", json: `{"mixed":[1,"string",true,null,{"key":"value"},[1,2,3]]}`, expected: `{ "mixed": [ 1, "string", true, null, { "key": "value" }, [ 1, 2, 3 ] ] }`, inline: both, }, { name: "table-like structure with inline", json: `{"table":[[1,2,3],[4,5,6],[7,8,9]]}`, expected: `{ "table": [ [ 1, 2, 3 ], [ 4, 5, 6 ], [ 7, 8, 9 ] ] }`, inline: yes, }, { name: "table-like structure without inline", json: `{"table":[[1,2,3],[4,5,6],[7,8,9]]}`, expected: `{ "table": [ [ 1, 2, 3 ], [ 4, 5, 6 ], [ 7, 8, 9 ] ] }`, inline: no, }, { name: "empty string with inline", json: `{"value":""}`, expected: `{ "value": "" }`, inline: both, }, { name: "very long string with inline", json: `{"longText":"This is a very long string that should not be inlined because it exceeds the maximum length for inlining. It should be displayed on its own line even when inlining is enabled."}`, expected: `{ "longText": "This is a very long string that should not be inlined because it exceeds the maximum length for inlining. It should be displayed on its own line even when inlining is enabled." }`, inline: both, }, { name: "very large array with inline", json: `{"largeArray":[1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30]}`, expected: `{ "largeArray": [ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30 ] }`, inline: yes, }, { name: "very large array without inline", json: `{"largeArray":[1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30]}`, expected: `{ "largeArray": [ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30 ] }`, inline: no, }, { name: "special number values without inline", json: `{"nan":NaN,"infinity":Infinity,"negInfinity":-Infinity}`, expected: `{ "nan": NaN, "infinity": Infinity, "negInfinity": -Infinity }`, inline: both, }, { name: "object with empty keys without inline", json: `{"":"empty key"}`, expected: `{ "": "empty key" }`, inline: both, }, { name: "array with single element with inline", json: `{"singleElement":[42]}`, expected: `{ "singleElement": [ 42 ] }`, inline: yes, }, { name: "array with single element without inline", json: `{"singleElement":[42]}`, expected: `{ "singleElement": [ 42 ] }`, inline: no, }, { name: "nested empty structures without inline", json: `{"emptyObject":{},"emptyArray":[],"nestedEmpty":{"empty":{},"alsoEmpty":[]}}`, expected: `{ "emptyObject": {}, "emptyArray": [], "nestedEmpty": { "empty": {}, "alsoEmpty": [] } }`, inline: both, }, { name: "extremely deep nesting without inline", json: `{"l1":{"l2":{"l3":{"l4":{"l5":{"l6":{"l7":{"l8":{"l9":{"l10":{"l11":{"l12":{"l13":{"l14":{"l15":{"l16":{"l17":{"l18":{"l19":{"l20":"deep"}}}}}}}}}}}}}}}}}}}}`, expected: `{ "l1": { "l2": { "l3": { "l4": { "l5": { "l6": { "l7": { "l8": { "l9": { "l10": { "l11": { "l12": { "l13": { "l14": { "l15": { "l16": { "l17": { "l18": { "l19": { "l20": "deep" } } } } } } } } } } } } } } } } } } } }`, inline: both, }, { name: "extremely long key name without inline", json: `{"thisIsAnExtremelyLongKeyNameThatShouldTestTheFormattingCapabilitiesOfThePrettyPrinterAndEnsureThatItHandlesVeryLongKeysCorrectlyWithoutBreakingOrCausingAnyIssuesInTheOutput":"value"}`, expected: `{ "thisIsAnExtremelyLongKeyNameThatShouldTestTheFormattingCapabilitiesOfThePrettyPrinterAndEnsureThatItHandlesVeryLongKeysCorrectlyWithoutBreakingOrCausingAnyIssuesInTheOutput": "value" }`, inline: both, }, { name: "unusual escape sequences without inline", json: `{"escapes":"\\u0000\\u0001\\u0002\\u0003\\u0004\\u0005\\u0006\\u0007\\b\\t\\n\\u000B\\f\\r\\u000E\\u000F"}`, expected: `{ "escapes": "\\u0000\\u0001\\u0002\\u0003\\u0004\\u0005\\u0006\\u0007\\b\\t\\n\\u000B\\f\\r\\u000E\\u000F" }`, inline: both, }, { name: "array with mixed sized elements without inline", json: `{"mixedArray":["small",{"medium":"object with some text"},{"large":"this is a much larger object with significantly more text that should cause different formatting depending on the pretty printer settings"}]}`, expected: `{ "mixedArray": [ "small", { "medium": "object with some text" }, { "large": "this is a much larger object with significantly more text that should cause different formatting depending on the pretty printer settings" } ] }`, inline: both, }, { name: "boundary number values with inline", json: `{"maxInt":9007199254740991,"minInt":-9007199254740991,"smallFloat":0.0000000000000001,"largeFloat":1.7976931348623157e+308,"smallNegativeFloat":-0.0000000000000001}`, expected: `{ "maxInt": 9007199254740991, "minInt": -9007199254740991, "smallFloat": 0.0000000000000001, "largeFloat": 1.7976931348623157e+308, "smallNegativeFloat": -0.0000000000000001 }`, inline: both, }, { name: "boundary number values without inline", json: `{"maxInt":9007199254740991,"minInt":-9007199254740991,"smallFloat":0.0000000000000001,"largeFloat":1.7976931348623157e+308,"smallNegativeFloat":-0.0000000000000001}`, expected: `{ "maxInt": 9007199254740991, "minInt": -9007199254740991, "smallFloat": 0.0000000000000001, "largeFloat": 1.7976931348623157e+308, "smallNegativeFloat": -0.0000000000000001 }`, inline: both, }, { name: "array with single element", json: `{"key":[42]}`, expected: `{ "key": [ 42 ] }`, inline: yes, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { node, err := jsonx.Parse([]byte(tt.json)) require.NoError(t, err) if tt.inline == both { { output := pretty.Print(node, true) strippedOutput := stripEscapeSequences(output) assert.Equal(t, tt.expected, strippedOutput, "Output doesn't match expected for %s", tt.name) } { output := pretty.Print(node, false) strippedOutput := stripEscapeSequences(output) assert.Equal(t, tt.expected, strippedOutput, "Output doesn't match expected for %s", tt.name) } } else { output := pretty.Print(node, tt.inline == yes) strippedOutput := stripEscapeSequences(output) assert.Equal(t, tt.expected, strippedOutput, "Output doesn't match expected for %s", tt.name) } }) } } ================================================ FILE: internal/shlex/shlex.go ================================================ /* Copyright 2012 Google Inc. All Rights Reserved. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ /* Package shlex implements a simple lexer which splits input in to tokens using shell-style rules for quoting and commenting. The basic use case uses the default ASCII lexer to split a string into sub-strings: shlex.Split("one \"two three\" four") -> []string{"one", "two three", "four"} To process a stream of strings: l := NewLexer(os.Stdin) for ; token, err := l.Next(); err != nil { // process token } To access the raw token stream (which includes tokens for comments): t := NewTokenizer(os.Stdin) for ; token, err := t.Next(); err != nil { // process token } */ package shlex import ( "bufio" "fmt" "io" "strings" ) // TokenType is a top-level token classification: A word, space, comment, unknown. type TokenType int // runeTokenClass is the type of a UTF-8 character classification: A quote, space, escape. type runeTokenClass int // the internal state used by the lexer state machine type lexerState int // Token is a (type, value) pair representing a lexographical token. type Token struct { tokenType TokenType value string } // Equal reports whether tokens a, and b, are equal. // Two tokens are equal if both their types and values are equal. A nil token can // never be equal to another token. func (a *Token) Equal(b *Token) bool { if a == nil || b == nil { return false } if a.tokenType != b.tokenType { return false } return a.value == b.value } // Named classes of UTF-8 runes const ( spaceRunes = " \t\r\n" escapingQuoteRunes = `"` nonEscapingQuoteRunes = "'" escapeRunes = `\` commentRunes = "#" ) // Classes of rune token const ( unknownRuneClass runeTokenClass = iota spaceRuneClass escapingQuoteRuneClass nonEscapingQuoteRuneClass escapeRuneClass commentRuneClass eofRuneClass ) // Classes of lexographic token const ( UnknownToken TokenType = iota WordToken SpaceToken CommentToken ) // Lexer state machine states const ( startState lexerState = iota // no runes have been seen inWordState // processing regular runes in a word escapingState // we have just consumed an escape rune; the next rune is literal escapingQuotedState // we have just consumed an escape rune within a quoted string quotingEscapingState // we are within a quoted string that supports escaping ("...") quotingState // we are within a string that does not support escaping ('...') commentState // we are within a comment (everything following an unquoted or unescaped # ) // tokenClassifier is used for classifying rune characters. type tokenClassifier map[rune]runeTokenClass func (typeMap tokenClassifier) addRuneClass(runes string, tokenType runeTokenClass) { for _, runeChar := range runes { typeMap[runeChar] = tokenType } } // newDefaultClassifier creates a new classifier for ASCII characters. func newDefaultClassifier() tokenClassifier { t := tokenClassifier{} t.addRuneClass(spaceRunes, spaceRuneClass) t.addRuneClass(escapingQuoteRunes, escapingQuoteRuneClass) t.addRuneClass(nonEscapingQuoteRunes, nonEscapingQuoteRuneClass) t.addRuneClass(escapeRunes, escapeRuneClass) t.addRuneClass(commentRunes, commentRuneClass) return t } // ClassifyRune classifiees a rune func (t tokenClassifier) ClassifyRune(runeVal rune) runeTokenClass { return t[runeVal] } // Lexer turns an input stream into a sequence of tokens. Whitespace and comments are skipped. type Lexer Tokenizer // NewLexer creates a new lexer from an input stream. func NewLexer(r io.Reader) *Lexer { return (*Lexer)(NewTokenizer(r)) } // Next returns the next word, or an error. If there are no more words, // the error will be io.EOF. func (l *Lexer) Next() (string, error) { for { token, err := (*Tokenizer)(l).Next() if err != nil { return "", err } switch token.tokenType { case WordToken: return token.value, nil case CommentToken: // skip comments default: return "", fmt.Errorf("Unknown token type: %v", token.tokenType) } } } // Tokenizer turns an input stream into a sequence of typed tokens type Tokenizer struct { input bufio.Reader classifier tokenClassifier } // NewTokenizer creates a new tokenizer from an input stream. func NewTokenizer(r io.Reader) *Tokenizer { input := bufio.NewReader(r) classifier := newDefaultClassifier() return &Tokenizer{ input: *input, classifier: classifier} } // scanStream scans the stream for the next token using the internal state machine. // It will panic if it encounters a rune which it does not know how to handle. func (t *Tokenizer) scanStream() (*Token, error) { state := startState var tokenType TokenType var value []rune var nextRune rune var nextRuneType runeTokenClass var err error for { nextRune, _, err = t.input.ReadRune() nextRuneType = t.classifier.ClassifyRune(nextRune) if err == io.EOF { nextRuneType = eofRuneClass err = nil } else if err != nil { return nil, err } switch state { case startState: // no runes read yet { switch nextRuneType { case eofRuneClass: { return nil, io.EOF } case spaceRuneClass: { } case escapingQuoteRuneClass: { tokenType = WordToken state = quotingEscapingState } case nonEscapingQuoteRuneClass: { tokenType = WordToken state = quotingState } case escapeRuneClass: { tokenType = WordToken state = escapingState } case commentRuneClass: { tokenType = CommentToken state = commentState } default: { tokenType = WordToken value = append(value, nextRune) state = inWordState } } } case inWordState: // in a regular word { switch nextRuneType { case eofRuneClass: { token := &Token{ tokenType: tokenType, value: string(value)} return token, err } case spaceRuneClass: { token := &Token{ tokenType: tokenType, value: string(value)} return token, err } case escapingQuoteRuneClass: { state = quotingEscapingState } case nonEscapingQuoteRuneClass: { state = quotingState } case escapeRuneClass: { state = escapingState } default: { value = append(value, nextRune) } } } case escapingState: // the rune after an escape character { switch nextRuneType { case eofRuneClass: { token := &Token{ tokenType: tokenType, value: string(value)} return token, nil } default: { state = inWordState value = append(value, nextRune) } } } case escapingQuotedState: // the next rune after an escape character, in double quotes { switch nextRuneType { case eofRuneClass: { token := &Token{ tokenType: tokenType, value: string(value)} return token, nil } default: { state = quotingEscapingState value = append(value, nextRune) } } } case quotingEscapingState: // in escaping double quotes { switch nextRuneType { case eofRuneClass: { token := &Token{ tokenType: tokenType, value: string(value)} return token, nil } case escapingQuoteRuneClass: { state = inWordState } case escapeRuneClass: { state = escapingQuotedState } default: { value = append(value, nextRune) } } } case quotingState: // in non-escaping single quotes { switch nextRuneType { case eofRuneClass: { token := &Token{ tokenType: tokenType, value: string(value)} return token, nil } case nonEscapingQuoteRuneClass: { state = inWordState } default: { value = append(value, nextRune) } } } case commentState: // in a comment { switch nextRuneType { case eofRuneClass: { token := &Token{ tokenType: tokenType, value: string(value)} return token, err } case spaceRuneClass: { if nextRune == '\n' { state = startState token := &Token{ tokenType: tokenType, value: string(value)} return token, err } else { value = append(value, nextRune) } } default: { value = append(value, nextRune) } } } default: { return nil, fmt.Errorf("Unexpected state: %v", state) } } } } // Next returns the next token in the stream. func (t *Tokenizer) Next() (*Token, error) { return t.scanStream() } // Split partitions a string into a slice of strings. func Split(s string) ([]string, error) { l := NewLexer(strings.NewReader(s)) subStrings := make([]string, 0) for { word, err := l.Next() if err != nil { if err == io.EOF { return subStrings, nil } return subStrings, err } subStrings = append(subStrings, word) } } // Parse removes the shell-style quoting from a string. func Parse(s string) string { l := NewLexer(strings.NewReader(s)) var b strings.Builder for { word, err := l.Next() if err != nil { if err == io.EOF { return b.String() } return "" } b.WriteString(word) } } ================================================ FILE: internal/shlex/shlex_test.go ================================================ /* Copyright 2012 Google Inc. All Rights Reserved. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package shlex import ( "strings" "testing" "github.com/stretchr/testify/require" ) var ( // one two "three four" "five \"six\"" seven#eight # nine # ten // eleven 'twelve\' testString = "one two \"three four\" \"five \\\"six\\\"\" seven#eight # nine # ten\n eleven 'twelve\\' thirteen=13 fourteen/14" ) func TestClassifier(t *testing.T) { classifier := newDefaultClassifier() tests := map[rune]runeTokenClass{ ' ': spaceRuneClass, '"': escapingQuoteRuneClass, '\'': nonEscapingQuoteRuneClass, '#': commentRuneClass} for runeChar, want := range tests { got := classifier.ClassifyRune(runeChar) if got != want { t.Errorf("ClassifyRune(%v) -> %v. Want: %v", runeChar, got, want) } } } func TestTokenizer(t *testing.T) { testInput := strings.NewReader(testString) expectedTokens := []*Token{ &Token{WordToken, "one"}, &Token{WordToken, "two"}, &Token{WordToken, "three four"}, &Token{WordToken, "five \"six\""}, &Token{WordToken, "seven#eight"}, &Token{CommentToken, " nine # ten"}, &Token{WordToken, "eleven"}, &Token{WordToken, "twelve\\"}, &Token{WordToken, "thirteen=13"}, &Token{WordToken, "fourteen/14"}} tokenizer := NewTokenizer(testInput) for i, want := range expectedTokens { got, err := tokenizer.Next() if err != nil { t.Error(err) } if !got.Equal(want) { t.Errorf("Tokenizer.Next()[%v] of %q -> %v. Want: %v", i, testString, got, want) } } } func TestLexer(t *testing.T) { testInput := strings.NewReader(testString) expectedStrings := []string{"one", "two", "three four", "five \"six\"", "seven#eight", "eleven", "twelve\\", "thirteen=13", "fourteen/14"} lexer := NewLexer(testInput) for i, want := range expectedStrings { got, err := lexer.Next() if err != nil { t.Error(err) } if got != want { t.Errorf("Lexer.Next()[%v] of %q -> %v. Want: %v", i, testString, got, want) } } } func TestSplit(t *testing.T) { want := []string{"one", "two", "three four", "five \"six\"", "seven#eight", "eleven", "twelve\\", "thirteen=13", "fourteen/14"} got, err := Split(testString) if err != nil { t.Error(err) } if len(want) != len(got) { t.Errorf("Split(%q) -> %v. Want: %v", testString, got, want) } for i := range got { if got[i] != want[i] { t.Errorf("Split(%q)[%v] -> %v. Want: %v", testString, i, got[i], want[i]) } } } func TestSplit_unfinished(t *testing.T) { got, err := Split("one 'two") require.NoError(t, err) require.Equal(t, []string{"one", "two"}, got) got, err = Split("one \"two") require.NoError(t, err) require.Equal(t, []string{"one", "two"}, got) } ================================================ FILE: internal/theme/theme.go ================================================ package theme import ( "encoding/json" "fmt" "os" "regexp" "sort" "github.com/charmbracelet/lipgloss" "github.com/muesli/termenv" "github.com/antonmedv/fx/internal/jsonx" ) type Theme struct { Cursor Color Syntax Color Preview Color StatusBar Color Search Color Key Color String Color Null Color Boolean Color Number Color Size Color Ref Color LineNumber Color Error Color } type Color func(s string) string func Value(kind jsonx.Kind) Color { switch kind { case jsonx.String: return CurrentTheme.String case jsonx.Bool: return CurrentTheme.Boolean case jsonx.Null: return CurrentTheme.Null case jsonx.Object, jsonx.Array: return CurrentTheme.Syntax case jsonx.Number: return CurrentTheme.Number case jsonx.NaN: return CurrentTheme.Error case jsonx.Infinity: return CurrentTheme.Error case jsonx.Undefined: return CurrentTheme.Error default: return noColor } } var ( TermOutput = termenv.NewOutput(os.Stderr) ) func init() { themeNames = make([]string, 0, len(themes)) for name := range themes { themeNames = append(themeNames, name) } sort.Strings(themeNames) themeId, ok := os.LookupEnv("FX_THEME") if !ok { themeId = "1" } CurrentTheme, ok = themes[themeId] if !ok { _, _ = fmt.Fprintf(os.Stderr, "fx: unknown theme %q, available themes: %v\n", themeId, themeNames) os.Exit(1) } if TermOutput.ColorProfile() == termenv.Ascii { CurrentTheme = themes["0"] } Colon = CurrentTheme.Syntax(": ") ColonPreview = CurrentTheme.Preview(":") Comma = CurrentTheme.Syntax(",") CommaPreview = CurrentTheme.Preview(",") Empty = CurrentTheme.Preview("~") Dot3 = CurrentTheme.Preview("…") CloseCurlyBracket = CurrentTheme.Syntax("}") CloseSquareBracket = CurrentTheme.Syntax("]") } var ( themeNames []string CurrentTheme Theme underline = toColor(lipgloss.NewStyle().Underline(true).Render) defaultCursor = toColor(lipgloss.NewStyle().Reverse(true).Render) defaultPreview = toColor(lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Render) defaultStatusBar = toColor(lipgloss.NewStyle().Background(lipgloss.Color("7")).Foreground(lipgloss.Color("0")).Render) defaultSearch = toColor(lipgloss.NewStyle().Background(lipgloss.Color("11")).Foreground(lipgloss.Color("16")).Render) defaultNull = fg("243") defaultSize = toColor(lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Render) defaultLineNumber = toColor(lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Render) defaultError = toColor(lipgloss.NewStyle().Background(lipgloss.Color("196")).Foreground(lipgloss.Color("255")).Render) ) var ( Colon string ColonPreview string Comma string CommaPreview string Empty string Dot3 string CloseCurlyBracket string CloseSquareBracket string ) var NoColor = Theme{ Cursor: defaultCursor, Syntax: noColor, Preview: noColor, StatusBar: noColor, Search: defaultSearch, Key: noColor, String: noColor, Null: noColor, Boolean: noColor, Number: noColor, Size: noColor, Ref: noColor, LineNumber: defaultLineNumber, Error: defaultError, } var themes = map[string]Theme{ "0": NoColor, "1": { Cursor: defaultCursor, Syntax: noColor, Preview: defaultPreview, StatusBar: defaultStatusBar, Search: defaultSearch, Key: boldFg("4"), String: fg("2"), Null: defaultNull, Boolean: fg("5"), Number: fg("6"), Size: defaultSize, Ref: underlineFg("2"), LineNumber: defaultLineNumber, Error: defaultError, }, "2": { Cursor: defaultCursor, Syntax: noColor, Preview: defaultPreview, StatusBar: defaultStatusBar, Search: defaultSearch, Key: fg("2"), String: fg("4"), Null: defaultNull, Boolean: fg("5"), Number: fg("6"), Size: defaultSize, Ref: underlineFg("4"), LineNumber: defaultLineNumber, Error: defaultError, }, "3": { Cursor: defaultCursor, Syntax: noColor, Preview: defaultPreview, StatusBar: defaultStatusBar, Search: defaultSearch, Key: fg("13"), String: fg("11"), Null: defaultNull, Boolean: fg("1"), Number: fg("14"), Size: defaultSize, Ref: underlineFg("11"), LineNumber: defaultLineNumber, Error: defaultError, }, "4": { Cursor: defaultCursor, Syntax: noColor, Preview: defaultPreview, StatusBar: defaultStatusBar, Search: defaultSearch, Key: fg("#00F5D4"), String: fg("#00BBF9"), Null: defaultNull, Boolean: fg("#F15BB5"), Number: fg("#9B5DE5"), Size: defaultSize, Ref: underlineFg("#00BBF9"), LineNumber: defaultLineNumber, Error: defaultError, }, "5": { Cursor: defaultCursor, Syntax: noColor, Preview: defaultPreview, StatusBar: defaultStatusBar, Search: defaultSearch, Key: fg("#faf0ca"), String: fg("#f4d35e"), Null: defaultNull, Boolean: fg("#ee964b"), Number: fg("#ee964b"), Size: defaultSize, Ref: underlineFg("#f4d35e"), LineNumber: defaultLineNumber, Error: defaultError, }, "6": { Cursor: defaultCursor, Syntax: noColor, Preview: defaultPreview, StatusBar: defaultStatusBar, Search: defaultSearch, Key: fg("#4D96FF"), String: fg("#6BCB77"), Null: defaultNull, Boolean: fg("#FF6B6B"), Number: fg("#FFD93D"), Size: defaultSize, Ref: underlineFg("#6BCB77"), LineNumber: defaultLineNumber, Error: defaultError, }, "7": { Cursor: defaultCursor, Syntax: noColor, Preview: defaultPreview, StatusBar: defaultStatusBar, Search: defaultSearch, Key: boldFg("42"), String: boldFg("213"), Null: defaultNull, Boolean: boldFg("201"), Number: boldFg("201"), Size: defaultSize, Ref: underlineFg("213"), LineNumber: defaultLineNumber, Error: defaultError, }, "8": { Cursor: defaultCursor, Syntax: noColor, Preview: defaultPreview, StatusBar: defaultStatusBar, Search: defaultSearch, Key: boldFg("51"), String: fg("195"), Null: defaultNull, Boolean: fg("50"), Number: fg("123"), Size: defaultSize, Ref: underlineFg("195"), LineNumber: defaultLineNumber, Error: defaultError, }, "9": { Cursor: defaultCursor, Syntax: noColor, Preview: defaultPreview, StatusBar: defaultStatusBar, Search: defaultSearch, Key: boldFg("39"), // deep sky blue String: fg("49"), // spring green 2 Null: defaultNull, Boolean: fg("205"), // hot pink Number: fg("220"), // gold Size: defaultSize, Ref: underlineFg("49"), LineNumber: defaultLineNumber, Error: defaultError, }, "🔵": { Cursor: toColor(lipgloss.NewStyle(). Foreground(lipgloss.Color("15")). Background(lipgloss.Color("33")). Render), Syntax: boldFg("33"), Preview: defaultPreview, StatusBar: defaultStatusBar, Search: defaultSearch, Key: fg("33"), String: noColor, Null: noColor, Boolean: noColor, Number: noColor, Size: defaultSize, Ref: underline, LineNumber: defaultLineNumber, Error: defaultError, }, "🥝": { Cursor: defaultCursor, Syntax: fg("179"), Preview: defaultPreview, StatusBar: defaultStatusBar, Search: defaultSearch, Key: boldFg("154"), String: fg("82"), Null: fg("230"), Boolean: fg("226"), Number: fg("226"), Size: defaultSize, Ref: underlineFg("82"), LineNumber: defaultLineNumber, Error: defaultError, }, "🔥": { Cursor: defaultCursor, Syntax: boldFg("208"), Preview: defaultPreview, StatusBar: defaultStatusBar, Search: defaultSearch, Key: boldFg("202"), String: fg("214"), Null: defaultNull, Boolean: fg("196"), Number: fg("202"), Size: defaultSize, Ref: underlineFg("214"), LineNumber: defaultLineNumber, Error: defaultError, }, "🟣": { Cursor: defaultCursor, Syntax: noColor, Preview: defaultPreview, StatusBar: defaultStatusBar, Search: defaultSearch, Key: boldFg("141"), // orchid String: fg("183"), // light pink/purple Null: defaultNull, Boolean: fg("81"), // cyan Number: fg("219"), // light magenta Size: defaultSize, Ref: underlineFg("183"), LineNumber: defaultLineNumber, Error: defaultError, }, } func noColor(s string) string { return s } func toColor(f func(s ...string) string) Color { return func(s string) string { return f(s) } } func fg(color string) Color { return toColor(lipgloss.NewStyle().Foreground(lipgloss.Color(color)).Render) } func underlineFg(color string) Color { return toColor(lipgloss.NewStyle().Underline(true).Foreground(lipgloss.Color(color)).Render) } func boldFg(color string) Color { return toColor(lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(color)).Render) } func ThemeTester() { for _, name := range themeNames { t := themes[name] comma := t.Syntax(",") colon := t.Syntax(":") fmt.Println(fmt.Sprintf("export FX_THEME=%q", name)) fmt.Println(t.Syntax("{")) fmt.Printf(" %v%v %v%v\n", t.Key("\"string\""), colon, t.String("\"Fox jumps over the lazy dog\""), comma) fmt.Printf(" %v%v %v%v\n", t.Key("\"number\""), colon, t.Number("1234567890"), comma) fmt.Printf(" %v%v %v%v\n", t.Key("\"boolean\""), colon, t.Boolean("true"), comma) fmt.Printf(" %v%v %v%v\n", t.Key("\"null\""), colon, t.Null("null"), comma) fmt.Printf(" %v%v %v%v%v\n", t.Key("\"collapsed\""), colon, t.Syntax("{"), t.Preview("\"preview\":…"), t.Syntax("}"), ) fmt.Println(t.Syntax("}")) println() } } func ExportThemes() { lipgloss.SetColorProfile(termenv.ANSI256) // Export in Terminal.app compatible colors placeholder := "_" extract := func(b string) string { matches := regexp. MustCompile(`^\x1b\[(.+)m_`). FindStringSubmatch(b) if len(matches) == 0 { return "" } else { return matches[1] } } var export = map[string][]string{} for _, name := range themeNames { t := themes[name] export[name] = append(export[name], extract(t.Syntax(placeholder))) export[name] = append(export[name], extract(t.Key(placeholder))) export[name] = append(export[name], extract(t.String(placeholder))) export[name] = append(export[name], extract(t.Number(placeholder))) export[name] = append(export[name], extract(t.Boolean(placeholder))) export[name] = append(export[name], extract(t.Null(placeholder))) } data, err := json.Marshal(export) if err != nil { panic(err) } fmt.Println(string(data)) } ================================================ FILE: internal/toml/toml.go ================================================ package toml import ( "bytes" "encoding/json" "io" "strings" toml "github.com/pelletier/go-toml/v2" "github.com/pelletier/go-toml/v2/unstable" "github.com/antonmedv/fx/internal/engine" ) type jnode interface{} type jobject struct { fields []jfield } type jfield struct { key string val jnode } type jarray struct { elems []jnode } func ToJSON(in []byte) ([]byte, error) { var typed any if err := toml.Unmarshal(in, &typed); err != nil { panic(in) } root := &jobject{} p := unstable.Parser{} p.Reset(in) aotActive := map[string]int{} // path -> current index for that AOT aotCount := map[string]int{} // path -> how many elems seen currentTablePath := []string{} for p.NextExpression() { e := p.Expression() switch e.Kind { case unstable.Table: currentTablePath = keyParts(e.Key()) _ = ensureContainer(root, currentTablePath, aotActive) case unstable.ArrayTable: path := keyParts(e.Key()) k := dot(path) idx := aotCount[k] aotCount[k] = idx + 1 aotActive[k] = idx currentTablePath = path _ = ensureContainer(root, path, aotActive) case unstable.KeyValue: rel := keyParts(e.Key()) // Resolve the actual typed value from the fully-decoded structure. val, ok := lookupTyped(typed, currentTablePath, rel, aotActive) if !ok { continue // skip gracefully on weird edge cases } obj := ensureContainer(root, currentTablePath, aotActive) setNested(obj, rel, toJ(val)) } } if err := p.Error(); err != nil { return nil, err } var buf bytes.Buffer if err := writeJSON(&buf, root); err != nil { return nil, err } return buf.Bytes(), nil } func keyParts(it unstable.Iterator) []string { var out []string for it.Next() { out = append(out, string(it.Node().Data)) } return out } func dot(parts []string) string { return strings.Join(parts, ".") } func ensureContainer(root *jobject, tablePath []string, aotActive map[string]int) *jobject { cur := root if len(tablePath) == 0 { return cur } prefix := []string{} for i, seg := range tablePath { prefix = append(prefix, seg) pkey := dot(prefix) // If this prefix is an active AOT, ensure an array & select the element. if idx, ok := aotActive[pkey]; ok { // Ensure field seg is an array f, exists := getField(cur, seg) if !exists { f = jfield{key: seg, val: &jarray{}} cur.fields = append(cur.fields, f) } arr, ok := f.val.(*jarray) if !ok { // Convert/replace if necessary arr = &jarray{} replaceField(cur, seg, arr) } // Ensure element at idx is an object for len(arr.elems) <= idx { arr.elems = append(arr.elems, &jobject{}) } elemObj, _ := arr.elems[idx].(*jobject) if elemObj == nil { elemObj = &jobject{} arr.elems[idx] = elemObj } cur = elemObj continue } // Regular table segment: ensure an object at seg child, ok := getField(cur, seg) if !ok { obj := &jobject{} cur.fields = append(cur.fields, jfield{key: seg, val: obj}) cur = obj continue } if obj, ok := child.val.(*jobject); ok { cur = obj } else { newObj := &jobject{} replaceField(cur, seg, newObj) cur = newObj } // If this isn't the last segment and we just ensured parent exists, // loop continues to drill down. if i == len(tablePath)-1 { // current table container reached } } return cur } func setNested(obj *jobject, parts []string, val jnode) { if len(parts) == 0 { return } cur := obj for i := 0; i < len(parts)-1; i++ { k := parts[i] f, ok := getField(cur, k) if !ok { n := &jobject{} cur.fields = append(cur.fields, jfield{key: k, val: n}) cur = n continue } if as, ok := f.val.(*jobject); ok { cur = as continue } // Replace if something else is there n := &jobject{} replaceField(cur, k, n) cur = n } // final key last := parts[len(parts)-1] if _, ok := getField(cur, last); ok { replaceField(cur, last, val) } else { cur.fields = append(cur.fields, jfield{key: last, val: val}) } } func getField(obj *jobject, key string) (jfield, bool) { for _, f := range obj.fields { if f.key == key { return f, true } } return jfield{}, false } func replaceField(obj *jobject, key string, val jnode) { for i := range obj.fields { if obj.fields[i].key == key { obj.fields[i].val = val return } } obj.fields = append(obj.fields, jfield{key: key, val: val}) } // Convert typed TOML -> jnode. Note: order inside *inline tables* cannot be // recovered from the typed map; if you need that too, we’d have to walk the // value expression via unstable APIs as well. func toJ(v any) jnode { switch x := v.(type) { case map[string]any: obj := &jobject{} // Map iteration order is undefined; this only affects inline tables. for k, vv := range x { obj.fields = append(obj.fields, jfield{key: k, val: toJ(vv)}) } return obj case []any: arr := &jarray{elems: make([]jnode, len(x))} for i := range x { arr.elems[i] = toJ(x[i]) } return arr default: return x // primitives, time.Time, etc. json.Marshal will handle them. } } func lookupTyped(typed any, tablePath, rel []string, aotActive map[string]int) (any, bool) { x := typed prefix := []string{} // Walk table path, applying AOT indices when present. for _, k := range tablePath { mp, ok := asMap(x) if !ok { return nil, false } var ok2 bool x, ok2 = mp[k] if !ok2 { return nil, false } prefix = append(prefix, k) if idx, ok := aotActive[dot(prefix)]; ok { arr, ok := x.([]any) if !ok || idx >= len(arr) { return nil, false } x = arr[idx] } } // Then the dotted relative key for _, k := range rel { mp, ok := asMap(x) if !ok { return nil, false } var ok2 bool x, ok2 = mp[k] if !ok2 { return nil, false } } return x, true } func asMap(x any) (map[string]any, bool) { if m, ok := x.(map[string]any); ok { return m, true } if m, ok := x.(map[string]interface{}); ok { return m, true } return nil, false } func writeJSON(w io.Writer, n jnode) error { switch v := n.(type) { case *jobject: if _, err := io.WriteString(w, "{"); err != nil { return err } for i, f := range v.fields { if i > 0 { if _, err := io.WriteString(w, ","); err != nil { return err } } // key kb, _ := json.Marshal(f.key) if _, err := w.Write(kb); err != nil { return err } if _, err := io.WriteString(w, ":"); err != nil { return err } if err := writeJSON(w, f.val); err != nil { return err } } _, err := io.WriteString(w, "}") return err case *jarray: if _, err := io.WriteString(w, "["); err != nil { return err } for i, e := range v.elems { if i > 0 { if _, err := io.WriteString(w, ","); err != nil { return err } } if err := writeJSON(w, e); err != nil { return err } } _, err := io.WriteString(w, "]") return err default: if str, ok := v.(string); ok { quoted := engine.Quote(str) _, err := w.Write([]byte(quoted)) return err } b, err := json.Marshal(v) if err != nil { return err } _, err = w.Write(b) return err } } ================================================ FILE: internal/toml/toml_test.go ================================================ package toml import ( "encoding/json" "reflect" "testing" "github.com/stretchr/testify/require" ) // helper: compare JSON as exact bytes func assertJSONBytesEqual(t *testing.T, got []byte, want string) { t.Helper() require.Equal(t, want, string(got), "unexpected JSON") } // helper: compare JSON by unmarshalling into interface{} (order-insensitive for objects) func assertJSONStructEqual(t *testing.T, got []byte, want string) { t.Helper() var g any var w any require.NoErrorf(t, json.Unmarshal(got, &g), "json.Unmarshal(got) failed; json: %s", string(got)) require.NoErrorf(t, json.Unmarshal([]byte(want), &w), "json.Unmarshal(want) failed; json: %s", want) if !reflect.DeepEqual(g, w) { gb, _ := json.Marshal(g) wb, _ := json.Marshal(w) require.Equal(t, string(wb), string(gb), "JSON structures differ") } } func TestToJSON_SimpleScalars(t *testing.T) { in := []byte(`a = 1 b = "x" c = true `) got, err := ToJSON(in) require.NoError(t, err) assertJSONBytesEqual(t, got, `{"a":1,"b":"x","c":true}`) } func TestToJSON_NestedTable(t *testing.T) { in := []byte(`[a] b = 1 `) got, err := ToJSON(in) require.NoError(t, err) assertJSONBytesEqual(t, got, `{"a":{"b":1}}`) } func TestToJSON_DottedKeys(t *testing.T) { in := []byte(`a.b.c = 2 a.b.d = 3 `) got, err := ToJSON(in) require.NoError(t, err) assertJSONBytesEqual(t, got, `{"a":{"b":{"c":2,"d":3}}}`) } func TestToJSON_ArraysAndInlineTables(t *testing.T) { in := []byte(`arr = [1, 2, "x"] obj = { b = 1, c = 2 } `) got, err := ToJSON(in) require.NoError(t, err) // Inline table field order is not guaranteed; compare structurally assertJSONStructEqual(t, got, `{"arr":[1,2,"x"],"obj":{"b":1,"c":2}}`) } func TestToJSON_ArraysOfTables(t *testing.T) { in := []byte(`[[fruit]] name = "apple" [fruit.variety] name = "red delicious" [[fruit]] name = "banana" `) got, err := ToJSON(in) require.NoError(t, err) assertJSONStructEqual(t, got, `{ "fruit": [ {"name":"apple","variety":{"name":"red delicious"}}, {"name":"banana"} ] }`) } func TestToJSON_NestedAOT(t *testing.T) { in := []byte(`[a] [[a.b]] x = 1 [[a.b]] x = 2 `) got, err := ToJSON(in) require.NoError(t, err) assertJSONStructEqual(t, got, `{"a":{"b":[{"x":1},{"x":2}]}}`) } func TestToJSON_MixedTablesAndKeysOrder(t *testing.T) { in := []byte(`title = "TOML Example" [owner] name = "Tom" age = 42 [database] server = "192.168.1.1" ports = [ 8001, 8001, 8002 ] `) got, err := ToJSON(in) require.NoError(t, err) // Expect compact JSON and correct nesting; top-level key order preserved assertJSONBytesEqual(t, got, `{"title":"TOML Example","owner":{"name":"Tom","age":42},"database":{"server":"192.168.1.1","ports":[8001,8001,8002]}}`) } func TestToJSON_Datetime(t *testing.T) { in := []byte(`dt = 1979-05-27T07:32:00Z `) got, err := ToJSON(in) require.NoError(t, err) // Marshal time values usually as RFC3339 strings var obj map[string]any require.NoError(t, json.Unmarshal(got, &obj)) v, ok := obj["dt"].(string) require.Truef(t, ok && v != "", "expected dt to be a JSON string, got: %T %v", obj["dt"], obj["dt"]) } ================================================ FILE: internal/utils/image.go ================================================ package utils import ( "bytes" "fmt" "image" _ "image/gif" _ "image/jpeg" _ "image/png" "io" "github.com/charmbracelet/lipgloss" "github.com/nfnt/resize" ) func DrawImage(r io.Reader, width, height int) (string, error) { img, _, err := image.Decode(r) if err != nil { return "", err } // width = number of horizontal "blocks" // height = number of vertical "blocks" // each block is two pixels tall, so max pixel dims are: maxW := uint(width) maxH := uint(height * 2) // Use Thumbnail to resize into the bounding box [maxW × maxH], preserving aspect ratio img = resize.Thumbnail(maxW, maxH, img, resize.Lanczos3) bounds := img.Bounds() var buffer bytes.Buffer for y := bounds.Min.Y + 1; y < bounds.Max.Y-1; y += 2 { for x := bounds.Min.X + 1; x < bounds.Max.X-1; x++ { r1, g1, b1, a1 := img.At(x, y+1).RGBA() r2, g2, b2, a2 := img.At(x, y).RGBA() // If both pixels are transparent, print a space. if a1 < 6553 && a2 < 6553 { buffer.WriteByte(' ') continue } colorStr1 := fmt.Sprintf("#%02X%02X%02X", r1>>8, g1>>8, b1>>8) colorStr2 := fmt.Sprintf("#%02X%02X%02X", r2>>8, g2>>8, b2>>8) block := lipgloss.NewStyle(). Foreground(lipgloss.Color(colorStr1)). Background(lipgloss.Color(colorStr2)). Render("▄") buffer.WriteString(block) } buffer.WriteByte('\n') } return buffer.String(), nil } ================================================ FILE: internal/utils/life.go ================================================ package utils import ( "bufio" "fmt" "math/rand" "os" "os/signal" "time" "github.com/charmbracelet/x/term" ) func GameOfLife() { w, rows, err := term.GetSize(os.Stdout.Fd()) if err != nil || w <= 0 || rows <= 0 { w, rows = 80, 24 } h := rows * 2 size := w * h s := make([]bool, size) switch rand.Int() % 3 { case 0: for i := 0; i < size; i++ { if rand.Float64() < 0.16 { s[i] = true } } case 1: cx := w/2 - 6 cy := h/2 - 7 s[cx+1+(2+cy)*w] = true s[cx+2+(1+cy)*w] = true s[cx+2+(3+cy)*w] = true s[cx+3+(2+cy)*w] = true s[cx+5+(15+cy)*w] = true s[cx+6+(13+cy)*w] = true s[cx+6+(15+cy)*w] = true s[cx+7+(12+cy)*w] = true s[cx+7+(13+cy)*w] = true s[cx+7+(15+cy)*w] = true s[cx+9+(11+cy)*w] = true s[cx+9+(12+cy)*w] = true s[cx+9+(13+cy)*w] = true case 2: s[1+5*w] = true s[1+6*w] = true s[2+5*w] = true s[2+6*w] = true s[12+5*w] = true s[12+6*w] = true s[12+7*w] = true s[13+4*w] = true s[13+8*w] = true s[14+3*w] = true s[14+9*w] = true s[15+4*w] = true s[15+8*w] = true s[16+5*w] = true s[16+6*w] = true s[16+7*w] = true s[17+5*w] = true s[17+6*w] = true s[17+7*w] = true s[22+3*w] = true s[22+4*w] = true s[22+5*w] = true s[23+2*w] = true s[23+3*w] = true s[23+5*w] = true s[23+6*w] = true s[24+2*w] = true s[24+3*w] = true s[24+5*w] = true s[24+6*w] = true s[25+2*w] = true s[25+3*w] = true s[25+4*w] = true s[25+5*w] = true s[25+6*w] = true s[26+w] = true s[26+2*w] = true s[26+6*w] = true s[26+7*w] = true s[35+3*w] = true s[35+4*w] = true s[36+3*w] = true s[36+4*w] = true } out := bufio.NewWriter(os.Stdout) esc := func(codes ...string) { for _, c := range codes { fmt.Fprintf(out, "\x1b[%s", c) } } esc("2J", "?25l") sigc := make(chan os.Signal, 1) signal.Notify(sigc, os.Interrupt) go func() { <-sigc esc("?25h") out.Flush() fmt.Printf("\n") os.Exit(2) }() at := func(i, j int) bool { if i < 0 { i = h - 1 } if i >= h { i = 0 } if j < 0 { j = w - 1 } if j >= w { j = 0 } return s[i*w+j] } fullBlock := "\u2588" topHalf := "\u2580" botHalf := "\u2584" ticker := time.NewTicker(30 * time.Millisecond) defer ticker.Stop() for { <-ticker.C esc("H") gen := make([]bool, size) for i := h - 1; i >= 0; i-- { for j := w - 1; j >= 0; j-- { n := 0 if at(i-1, j-1) { n++ } if at(i-1, j) { n++ } if at(i-1, j+1) { n++ } if at(i, j-1) { n++ } if at(i, j+1) { n++ } if at(i+1, j-1) { n++ } if at(i+1, j) { n++ } if at(i+1, j+1) { n++ } z := i*w + j if s[z] { gen[z] = n == 2 || n == 3 } else { gen[z] = n == 3 } } } s = gen for i := 0; i < rows; i++ { for j := 0; j < w; j++ { top := s[i*2*w+j] bot := s[(i*2+1)*w+j] switch { case top && bot: out.WriteString(fullBlock) case top && !bot: out.WriteString(topHalf) case !top && bot: out.WriteString(botHalf) default: out.WriteByte(' ') } } if i != rows-1 { out.WriteByte('\n') } } out.Flush() } } ================================================ FILE: internal/utils/utils.go ================================================ package utils import ( "encoding/json" ) func IsHexDigit(ch byte) bool { return (ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f') || (ch >= 'A' && ch <= 'F') } func IsDigit(ch byte) bool { return ch >= '0' && ch <= '9' } func Contains(needle int, haystack []int) bool { for _, i := range haystack { if needle == i { return true } } return false } func Unquote(s string) (string, error) { var unquoted string err := json.Unmarshal([]byte(s), &unquoted) return unquoted, err } ================================================ FILE: internal/utils/utils_test.go ================================================ package utils import ( "testing" "github.com/stretchr/testify/assert" ) func TestIsHexDigit(t *testing.T) { tests := []struct { name string input byte expected bool }{ {"digit 0", '0', true}, {"digit 5", '5', true}, {"digit 9", '9', true}, {"lowercase a", 'a', true}, {"lowercase f", 'f', true}, {"uppercase A", 'A', true}, {"uppercase F", 'F', true}, {"lowercase g", 'g', false}, {"uppercase G", 'G', false}, {"space", ' ', false}, {"lowercase z", 'z', false}, {"special char", '@', false}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { result := IsHexDigit(tc.input) assert.Equal(t, tc.expected, result) }) } } func TestIsDigit(t *testing.T) { tests := []struct { name string input byte expected bool }{ {"digit 0", '0', true}, {"digit 5", '5', true}, {"digit 9", '9', true}, {"lowercase a", 'a', false}, {"uppercase A", 'A', false}, {"space", ' ', false}, {"special char", '-', false}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { result := IsDigit(tc.input) assert.Equal(t, tc.expected, result) }) } } func TestContains(t *testing.T) { tests := []struct { name string needle int haystack []int expected bool }{ {"found at start", 1, []int{1, 2, 3}, true}, {"found in middle", 2, []int{1, 2, 3}, true}, {"found at end", 3, []int{1, 2, 3}, true}, {"not found", 4, []int{1, 2, 3}, false}, {"empty slice", 1, []int{}, false}, {"single element found", 1, []int{1}, true}, {"single element not found", 2, []int{1}, false}, {"negative number found", -1, []int{-1, 0, 1}, true}, {"zero found", 0, []int{-1, 0, 1}, true}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { result := Contains(tc.needle, tc.haystack) assert.Equal(t, tc.expected, result) }) } } func TestUnquote(t *testing.T) { tests := []struct { name string input string expected string hasError bool }{ {"simple string", `"hello"`, "hello", false}, {"empty string", `""`, "", false}, {"with spaces", `"hello world"`, "hello world", false}, {"with escape", `"hello\nworld"`, "hello\nworld", false}, {"with tab", `"hello\tworld"`, "hello\tworld", false}, {"with unicode", `"hello \u4e16\u754c"`, "hello 世界", false}, {"with quotes inside", `"hello \"world\""`, `hello "world"`, false}, {"with backslash", `"hello\\world"`, `hello\world`, false}, {"invalid json", `hello`, "", true}, {"number", `123`, "", true}, {"unclosed quote", `"hello`, "", true}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { result, err := Unquote(tc.input) if tc.hasError { assert.Error(t, err) } else { assert.NoError(t, err) assert.Equal(t, tc.expected, result) } }) } } ================================================ FILE: keymap.go ================================================ package main import "github.com/charmbracelet/bubbles/key" type KeyMap struct { Up key.Binding `category:"Navigation"` Down key.Binding `category:"Navigation"` PageUp key.Binding `category:"Navigation"` PageDown key.Binding `category:"Navigation"` HalfPageUp key.Binding `category:"Navigation"` HalfPageDown key.Binding `category:"Navigation"` GotoTop key.Binding `category:"Navigation"` GotoBottom key.Binding `category:"Navigation"` NextSibling key.Binding `category:"Navigation"` PrevSibling key.Binding `category:"Navigation"` Expand key.Binding `category:"Expand / Collapse"` Collapse key.Binding `category:"Expand / Collapse"` ExpandRecursively key.Binding `category:"Expand / Collapse"` CollapseRecursively key.Binding `category:"Expand / Collapse"` ExpandAll key.Binding `category:"Expand / Collapse"` CollapseAll key.Binding `category:"Expand / Collapse"` CollapseLevel key.Binding `category:"Expand / Collapse"` Search key.Binding `category:"Search"` SearchNext key.Binding `category:"Search"` SearchPrev key.Binding `category:"Search"` GotoSymbol key.Binding `category:"Search"` GotoRef key.Binding `category:"Search"` Yank key.Binding `category:"Actions"` Delete key.Binding `category:"Actions"` Preview key.Binding `category:"Actions"` Print key.Binding `category:"Actions"` Open key.Binding `category:"Actions"` ToggleWrap key.Binding `category:"View"` ShowSelector key.Binding `category:"View"` GoBack key.Binding `category:"Navigation"` GoForward key.Binding `category:"Navigation"` Help key.Binding `category:"Other"` CommandLine key.Binding `category:"Other"` Quit key.Binding `category:"Other"` Suspend key.Binding `category:"Other"` } var keyMap KeyMap func init() { keyMap = KeyMap{ Quit: key.NewBinding( key.WithKeys("q", "ctrl+c", "esc"), key.WithHelp("", "exit program"), ), Suspend: key.NewBinding( key.WithKeys("ctrl+z"), key.WithHelp("", "suspend program"), ), PageDown: key.NewBinding( key.WithKeys("pgdown", " ", "f"), key.WithHelp("pgdown, space, f", "page down"), ), PageUp: key.NewBinding( key.WithKeys("pgup", "b"), key.WithHelp("pgup, b", "page up"), ), HalfPageUp: key.NewBinding( key.WithKeys("ctrl+u"), key.WithHelp("", "half page up"), ), HalfPageDown: key.NewBinding( key.WithKeys("ctrl+d"), key.WithHelp("", "half page down"), ), GotoTop: key.NewBinding( key.WithKeys("g", "home"), key.WithHelp("", "goto top"), ), GotoBottom: key.NewBinding( key.WithKeys("G", "end"), key.WithHelp("", "goto bottom"), ), GotoSymbol: key.NewBinding( key.WithKeys("@"), key.WithHelp("", "goto symbol"), ), GotoRef: key.NewBinding( key.WithKeys("ctrl+g"), key.WithHelp("", "goto ref"), ), Down: key.NewBinding( key.WithKeys("down", "j"), key.WithHelp("", "down"), ), Up: key.NewBinding( key.WithKeys("up", "k"), key.WithHelp("", "up"), ), Help: key.NewBinding( key.WithKeys("?"), key.WithHelp("", "show help"), ), Expand: key.NewBinding( key.WithKeys("right", "l", "enter"), key.WithHelp("", "expand"), ), Collapse: key.NewBinding( key.WithKeys("left", "h", "backspace"), key.WithHelp("", "collapse"), ), ExpandRecursively: key.NewBinding( key.WithKeys("L", "shift+right"), key.WithHelp("", "expand recursively"), ), CollapseRecursively: key.NewBinding( key.WithKeys("H", "shift+left"), key.WithHelp("", "collapse recursively"), ), ExpandAll: key.NewBinding( key.WithKeys("e"), key.WithHelp("", "expand all"), ), CollapseAll: key.NewBinding( key.WithKeys("E"), key.WithHelp("", "collapse all"), ), CollapseLevel: key.NewBinding( key.WithKeys("1", "2", "3", "4", "5", "6", "7", "8", "9"), key.WithHelp("", "collapse to nth level"), ), NextSibling: key.NewBinding( key.WithKeys("J", "shift+down"), key.WithHelp("", "next sibling"), ), PrevSibling: key.NewBinding( key.WithKeys("K", "shift+up"), key.WithHelp("", "previous sibling"), ), ToggleWrap: key.NewBinding( key.WithKeys("z"), key.WithHelp("", "toggle strings wrap"), ), ShowSelector: key.NewBinding( key.WithKeys("s"), key.WithHelp("", "show sizes/line numbers"), ), Yank: key.NewBinding( key.WithKeys("y"), key.WithHelp("", "yank/copy"), ), Delete: key.NewBinding( key.WithKeys("d"), key.WithHelp("", "delete node"), ), CommandLine: key.NewBinding( key.WithKeys(":"), key.WithHelp("", "open command line"), ), Search: key.NewBinding( key.WithKeys("/"), key.WithHelp("", "search regexp"), ), SearchNext: key.NewBinding( key.WithKeys("n"), key.WithHelp("", "next search result"), ), SearchPrev: key.NewBinding( key.WithKeys("N"), key.WithHelp("", "prev search result"), ), Preview: key.NewBinding( key.WithKeys("p"), key.WithHelp("", "preview node"), ), Print: key.NewBinding( key.WithKeys("P"), key.WithHelp("", "print to stdout"), ), Open: key.NewBinding( key.WithKeys("v"), key.WithHelp("", "open in editor"), ), GoBack: key.NewBinding( key.WithKeys("["), key.WithHelp("", "go back"), ), GoForward: key.NewBinding( key.WithKeys("]"), key.WithHelp("", "go forward"), ), } } var ( yankValueY = key.NewBinding(key.WithKeys("y")) yankValueV = key.NewBinding(key.WithKeys("v")) yankKey = key.NewBinding(key.WithKeys("k")) yankPath = key.NewBinding(key.WithKeys("p")) yankKeyValue = key.NewBinding(key.WithKeys("b")) arrowUp = key.NewBinding(key.WithKeys("up")) arrowDown = key.NewBinding(key.WithKeys("down")) showSizes = key.NewBinding(key.WithKeys("s")) showLineNumbers = key.NewBinding(key.WithKeys("l")) ) ================================================ FILE: main.go ================================================ package main import ( "bytes" "encoding/base64" "encoding/json" "flag" "fmt" "io" "math" "os" "os/exec" "path/filepath" "runtime/pprof" "strconv" "strings" "github.com/antonmedv/clipboard" "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/spinner" "github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/mattn/go-isatty" "github.com/antonmedv/fx/internal/complete" "github.com/antonmedv/fx/internal/engine" "github.com/antonmedv/fx/internal/fuzzy" "github.com/antonmedv/fx/internal/jsonpath" . "github.com/antonmedv/fx/internal/jsonx" "github.com/antonmedv/fx/internal/theme" "github.com/antonmedv/fx/internal/toml" "github.com/antonmedv/fx/internal/utils" ) var ( flagYaml bool flagToml bool flagRaw bool flagSlurp bool flagComp bool flagStrict bool flagNoInline bool ) var flags = []string{ "--help", "--raw", "--slurp", "--themes", "--version", "--yaml", "--toml", "--strict", "--no-inline", } func init() { for _, name := range flags { complete.Flags = append(complete.Flags, complete.Reply{name, name, "flag"}) } } func main() { if _, ok := os.LookupEnv("FX_PPROF"); ok { f, err := os.Create("cpu.prof") if err != nil { panic(err) } err = pprof.StartCPUProfile(f) if err != nil { panic(err) } defer f.Close() defer pprof.StopCPUProfile() memProf, err := os.Create("mem.prof") if err != nil { panic(err) } defer memProf.Close() defer pprof.WriteHeapProfile(memProf) } if complete.Complete() { os.Exit(0) return } var args []string for _, arg := range os.Args[1:] { if strings.HasPrefix(arg, "--comp") { flagComp = true continue } switch arg { case "-h", "--help": fmt.Println(usage()) return case "-v", "-V", "--version": fmt.Println(version) return case "--themes": theme.ThemeTester() return case "--export-themes": theme.ExportThemes() return case "--yaml": flagYaml = true case "--toml": flagToml = true case "--raw", "-r": flagRaw = true case "--slurp", "-s": flagSlurp = true case "-rs", "-sr": flagRaw = true flagSlurp = true case "--strict": flagStrict = true case "--no-inline": flagNoInline = true case "--game-of-life": utils.GameOfLife() return default: args = append(args, arg) } } if (flagYaml || flagToml) && flagRaw { println("Error: can't use --yaml/--toml and --raw flags together") os.Exit(1) } if flagYaml && flagToml { println("Error: can't use both --yaml and --toml flags together") os.Exit(1) } if flagComp { shell := flag.String("comp", "", "") flag.Parse() switch *shell { case "bash": fmt.Print(complete.Bash) case "zsh": fmt.Print(complete.Zsh) case "fish": fmt.Print(complete.Fish) default: fmt.Println("unknown shell type") } return } fd := os.Stdin.Fd() stdinIsTty := isatty.IsTerminal(fd) || isatty.IsCygwinTerminal(fd) var fileName string var src io.Reader if stdinIsTty { if len(args) == 0 { // $ fx fmt.Println(usage()) return } else { // $ fx file.json arg* filePath := args[0] src = open(filePath, &flagYaml, &flagToml) engine.FilePath = filePath fileName = filepath.Base(filePath) args = args[1:] } } else { // cat file.json | fx arg* src = os.Stdin } var parser engine.Parser if flagYaml { b, err := io.ReadAll(src) if err != nil { panic(err) } jsonBytes, err := parseYAML(b) if err != nil { fmt.Print(err.Error()) os.Exit(1) return } parser = NewJsonParser(bytes.NewReader(jsonBytes), flagStrict) } else if flagToml { b, err := io.ReadAll(src) if err != nil { panic(err) } jsonBytes, err := toml.ToJSON(b) if err != nil { fmt.Print(err.Error()) os.Exit(1) return } parser = NewJsonParser(bytes.NewReader(jsonBytes), flagStrict) } else if flagRaw { parser = NewLineParser(src) } else { parser = NewJsonParser(src, flagStrict) } if len(args) > 0 || flagSlurp { opts := engine.Options{ Slurp: flagSlurp, WithInline: !flagNoInline, WriteOut: func(s string) { fmt.Println(s) }, WriteErr: func(s string) { fmt.Fprintln(os.Stderr, s) }, } exitCode := engine.Start(parser, args, opts) if exitCode != 0 { os.Exit(exitCode) } return } commandInput := textinput.New() commandInput.Prompt = ":" searchInput := textinput.New() searchInput.Prompt = "/" gotoSymbolInput := textinput.New() gotoSymbolInput.Prompt = "@" previewSearchInput := textinput.New() previewSearchInput.Prompt = "/" spinnerModel := spinner.New() spinnerModel.Spinner = spinner.MiniDot collapsed := false if _, ok := os.LookupEnv("FX_COLLAPSED"); ok { collapsed = true } showLineNumbers := false if _, ok := os.LookupEnv("FX_LINE_NUMBERS"); ok { showLineNumbers = true } showSizes := false showSizesValue, ok := os.LookupEnv("FX_SHOW_SIZE") if ok { showSizesValue := strings.ToLower(showSizesValue) showSizes = showSizesValue == "true" || showSizesValue == "yes" || showSizesValue == "on" || showSizesValue == "1" } m := &model{ suspending: false, showCursor: true, wrap: true, collapsed: collapsed, showSizes: showSizes, showLineNumbers: showLineNumbers, fileName: fileName, gotoSymbolInput: gotoSymbolInput, commandInput: commandInput, searchInput: searchInput, search: newSearch(), previewSearchInput: previewSearchInput, previewSearchCursor: -1, spinner: spinnerModel, } lipgloss.SetColorProfile(theme.TermOutput.ColorProfile()) withMouse := tea.WithMouseCellMotion() if _, ok := os.LookupEnv("FX_NO_MOUSE"); ok { withMouse = tea.WithAltScreen() } p := tea.NewProgram(m, tea.WithAltScreen(), withMouse, tea.WithOutput(os.Stderr), ) go func() { firstOk := false for { node, err := parser.Parse() if err != nil { if err == io.EOF { p.Send(eofMsg{}) break } if flagStrict { p.Send(errorMsg{err: err}) break } textNode := parser.Recover() if !firstOk && !strings.HasPrefix(textNode.Value, "HTTP") { p.Send(errorMsg{err: err}) break } p.Send(nodeMsg{node: textNode}) } else { firstOk = true p.Send(nodeMsg{node: node}) } } }() _, err := p.Run() if err != nil { panic(err) } if m.printErrorOnExit != nil { fmt.Println(m.printErrorOnExit.Error()) } else if m.printOnExit { fmt.Println(m.cursorValue()) } else { exit() } } type model struct { termWidth, termHeight int head, top, bottom *Node eof bool cursor int // cursor position [0, termHeight) suspending bool showCursor bool wrap bool collapsed bool showShowSelector bool showSizes bool showLineNumbers bool totalLines int fileName string gotoSymbolInput textinput.Model commandInput textinput.Model searchInput textinput.Model search *search searching bool // search in progress searchCancel chan struct{} // cancel channel for search searchID uint64 // increments with each search to detect stale results yank bool showHelp bool help viewport.Model showPreview bool preview viewport.Model previewValue string previewSearchInput textinput.Model previewSearchResults []int previewSearchCursor int printOnExit bool printErrorOnExit error spinner spinner.Model locationHistory []location locationIndex int // position in locationHistory keysIndex []string keysIndexNodes []*Node fuzzyMatch *fuzzy.Match deletePending bool } type location struct { head *Node node *Node } type nodeMsg struct { node *Node } type errorMsg struct { err error } type eofMsg struct{} type searchResultMsg struct { id uint64 query string search *search } type searchCancelledMsg struct { id uint64 } func (m *model) Init() tea.Cmd { return m.spinner.Tick } func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: m.termWidth = msg.Width m.termHeight = msg.Height m.help.Width = m.termWidth m.help.Height = m.termHeight - 1 m.preview.Width = m.termWidth m.preview.Height = m.termHeight - 1 Wrap(m.top, m.viewWidth()) m.redoSearch() case eofMsg: m.eof = true return m, nil case errorMsg: m.printErrorOnExit = msg.err return m, tea.Quit case nodeMsg: if m.wrap { Wrap(msg.node, m.viewWidth()) } if m.collapsed { msg.node.CollapseRecursively() } m.totalLines = msg.node.Bottom().LineNumber if m.head == nil { m.head = msg.node m.top = msg.node m.bottom = msg.node } else { to, ok := m.cursorPointsTo() if !ok { return m, nil } scrollToBottom := to == m.bottom.Bottom() msg.node.Index = -1 // To fix the statusbar path (to show .key instead of [0].key). m.bottom.Adjacent(msg.node) m.bottom = msg.node if scrollToBottom { m.scrollToBottom() } } return m, nil case spinner.TickMsg: if !m.eof || m.searching { var cmd tea.Cmd m.spinner, cmd = m.spinner.Update(msg) return m, cmd } case searchResultMsg: if msg.id != m.searchID { return m, nil } m.searching = false m.searchCancel = nil if msg.search != nil { m.search = msg.search m.selectSearchResult(0) } return m, nil case searchCancelledMsg: if msg.id != m.searchID { return m, nil } m.searching = false m.searchCancel = nil return m, nil case tea.ResumeMsg: m.suspending = false return m, nil } if m.showHelp { return m.handleHelpKey(msg) } if m.showPreview { return m.handlePreviewKey(msg) } switch msg := msg.(type) { case tea.MouseMsg: m.handlePendingDelete(msg) switch { case msg.Button == tea.MouseButtonWheelUp: m.up() case msg.Button == tea.MouseButtonWheelDown: m.down() case msg.Button == tea.MouseButtonLeft && msg.Action == tea.MouseActionPress: m.showCursor = true if msg.Y < m.viewHeight() { if m.cursor == msg.Y { to, ok := m.cursorPointsTo() if ok { if to.IsCollapsed() { to.Expand() } else { to.Collapse() } value, isRef := isRefNode(to) if isRef { refPath, ok := jsonpath.ParseSchemaRef(value) if ok { m.selectNode(m.findByPath(refPath)) m.recordHistory() } } } } else { to := m.at(msg.Y) if to != nil { m.cursor = msg.Y if to.IsCollapsed() { to.Expand() } } } m.recordHistory() } } case tea.KeyMsg: if m.commandInput.Focused() { return m.handleGotoLineKey(msg) } if m.searchInput.Focused() { return m.handleSearchKey(msg) } if m.gotoSymbolInput.Focused() { return m.handleGotoSymbolKey(msg) } if m.yank { return m.handleYankKey(msg) } if m.showShowSelector { return m.handleShowSelectorKey(msg) } return m.handleKey(msg) } return m, nil } func (m *model) handleHelpKey(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd if msg, ok := msg.(tea.KeyMsg); ok { switch { case key.Matches(msg, keyMap.Quit), key.Matches(msg, keyMap.Help): m.showHelp = false } } m.help, cmd = m.help.Update(msg) return m, cmd } func (m *model) handleGotoLineKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { var cmd tea.Cmd switch { case msg.Type == tea.KeyEscape: m.commandInput.Blur() m.commandInput.SetValue("") m.showCursor = true case msg.Type == tea.KeyEnter: m.commandInput.Blur() command := m.commandInput.Value() m.commandInput.SetValue("") return m.runCommand(command) default: m.commandInput, cmd = m.commandInput.Update(msg) } return m, cmd } func (m *model) handleSearchKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { var cmd tea.Cmd switch { case msg.Type == tea.KeyEscape: m.cancelSearch() m.search = newSearch() m.searchInput.Blur() m.searchInput.SetValue("") m.showCursor = true case msg.Type == tea.KeyEnter: m.searchInput.Blur() m.cancelSearch() m.search = newSearch() return m, m.doSearch(m.searchInput.Value()) default: m.searchInput, cmd = m.searchInput.Update(msg) } return m, cmd } func (m *model) handleGotoSymbolKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { var cmd tea.Cmd switch msg.Type { case tea.KeyEscape, tea.KeyEnter, tea.KeyUp, tea.KeyDown: m.gotoSymbolInput.Blur() m.gotoSymbolInput.SetValue("") m.recordHistory() default: m.gotoSymbolInput, cmd = m.gotoSymbolInput.Update(msg) pattern := []rune(m.gotoSymbolInput.Value()) found := fuzzy.Find(pattern, m.keysIndex) if found != nil { m.fuzzyMatch = found m.selectNode(m.keysIndexNodes[found.Index]) } } switch msg.Type { case tea.KeyUp: m.up() case tea.KeyDown: m.down() } return m, cmd } func (m *model) handleYankKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch { case key.Matches(msg, yankPath): _ = clipboard.WriteAll(m.cursorPath()) case key.Matches(msg, yankKey): _ = clipboard.WriteAll(m.cursorKey()) case key.Matches(msg, yankValueY, yankValueV): _ = clipboard.WriteAll(m.cursorValue()) case key.Matches(msg, yankKeyValue): k := m.cursorKey() v := m.cursorValue() keyValue := k + ": " + v _ = clipboard.WriteAll(keyValue) } m.yank = false return m, nil } func (m *model) handleShowSelectorKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch { case key.Matches(msg, showSizes): m.showSizes = !m.showSizes case key.Matches(msg, showLineNumbers): m.showLineNumbers = !m.showLineNumbers Wrap(m.top, m.viewWidth()) } m.showShowSelector = false return m, nil } func (m *model) handlePendingDelete(msg tea.Msg) { // Handle potential 'dd' sequence for delete if m.deletePending { if keyMsg, ok := msg.(tea.KeyMsg); ok { if key.Matches(keyMsg, keyMap.Delete) { m.deleteAtCursor() m.deletePending = true return } } m.deletePending = false } } func (m *model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.handlePendingDelete(msg) switch { case key.Matches(msg, keyMap.Suspend): m.suspending = true return m, tea.Suspend case key.Matches(msg, keyMap.Quit): return m, tea.Quit case key.Matches(msg, keyMap.Help): m.help.SetContent(help(keyMap)) m.showHelp = true case key.Matches(msg, keyMap.Up): m.up() case key.Matches(msg, keyMap.Down): m.down() case key.Matches(msg, keyMap.PageUp): m.cursor = m.viewHeight() - 1 m.showCursor = true m.scrollBackward(max(0, m.viewHeight()-2)) m.scrollIntoView() // As the cursor is at the bottom, and it may be empty. m.recordHistory() case key.Matches(msg, keyMap.PageDown): m.cursor = 0 m.showCursor = true m.scrollForward(max(0, m.viewHeight()-2)) m.recordHistory() case key.Matches(msg, keyMap.HalfPageUp): m.showCursor = true m.scrollBackward(m.viewHeight() / 2) m.scrollIntoView() // As the cursor stays at the same position, and it may be empty. m.recordHistory() case key.Matches(msg, keyMap.HalfPageDown): m.showCursor = true m.scrollForward(m.viewHeight() / 2) m.scrollIntoView() // As the cursor stays at the same position, and it may be empty. m.recordHistory() case key.Matches(msg, keyMap.GotoTop): m.head = m.top m.cursor = 0 m.showCursor = true m.recordHistory() case key.Matches(msg, keyMap.GotoBottom): m.scrollToBottom() m.recordHistory() case key.Matches(msg, keyMap.NextSibling): pointsTo, ok := m.cursorPointsTo() if !ok { return m, nil } var nextSibling *Node if pointsTo.End != nil && pointsTo.End.Next != nil { nextSibling = pointsTo.End.Next } else if pointsTo.ChunkEnd != nil && pointsTo.ChunkEnd.Next != nil { nextSibling = pointsTo.ChunkEnd.Next } else { nextSibling = pointsTo.Next } if nextSibling != nil { m.selectNode(nextSibling) } m.recordHistory() case key.Matches(msg, keyMap.PrevSibling): pointsTo, ok := m.cursorPointsTo() if !ok { return m, nil } var prevSibling *Node parent := pointsTo.Parent if parent != nil && parent.End == pointsTo { prevSibling = parent } else if pointsTo.Prev != nil { prevSibling = pointsTo.Prev parent := prevSibling.Parent if parent != nil && parent.End == prevSibling { prevSibling = parent } else if prevSibling.Chunk != "" { prevSibling = parent } } if prevSibling != nil { m.selectNode(prevSibling) } m.recordHistory() case key.Matches(msg, keyMap.Collapse): n, ok := m.cursorPointsTo() if !ok { return m, nil } if n.HasChildren() && !n.IsCollapsed() { n.Collapse() } else { if n.Parent != nil { n = n.Parent } } m.selectNode(n) m.recordHistory() case key.Matches(msg, keyMap.Expand): n, ok := m.cursorPointsTo() if !ok { return m, nil } n.Expand() m.showCursor = true case key.Matches(msg, keyMap.CollapseRecursively): n, ok := m.cursorPointsTo() if !ok { return m, nil } if n.HasChildren() { n.CollapseRecursively() } m.showCursor = true case key.Matches(msg, keyMap.ExpandRecursively): n, ok := m.cursorPointsTo() if !ok { return m, nil } if n.HasChildren() { n.ExpandRecursively(0, math.MaxInt) } m.showCursor = true case key.Matches(msg, keyMap.CollapseAll): at, ok := m.cursorPointsTo() if ok { m.collapsed = true n := m.top for n != nil { if n.Kind != Err { n.CollapseRecursively() } if n.End == nil { n = nil } else { n = n.End.Next } } m.selectNode(at.Root()) m.recordHistory() } case key.Matches(msg, keyMap.ExpandAll): at, ok := m.cursorPointsTo() if !ok { return m, nil } m.collapsed = false n := m.top for n != nil { n.ExpandRecursively(0, math.MaxInt) if n.End == nil { n = nil } else { n = n.End.Next } } m.selectNode(at) case key.Matches(msg, keyMap.CollapseLevel): at, ok := m.cursorPointsTo() if ok && at.HasChildren() { toLevel, _ := strconv.Atoi(msg.String()) at.CollapseRecursively() at.ExpandRecursively(0, toLevel) m.showCursor = true } case key.Matches(msg, keyMap.ToggleWrap): at, ok := m.cursorPointsTo() if !ok { return m, nil } m.wrap = !m.wrap if m.wrap { Wrap(m.top, m.viewWidth()) } else { DropWrapAll(m.top) } if at.Chunk != "" && at.Value == "" { at = at.Parent } m.redoSearch() m.selectNode(at) case key.Matches(msg, keyMap.ShowSelector): m.showShowSelector = true case key.Matches(msg, keyMap.Yank): m.yank = true case key.Matches(msg, keyMap.Preview): m.showPreview = true value := m.cursorValue() var view string if decodedValue, err := base64.StdEncoding.DecodeString(value); err == nil { img, err := utils.DrawImage(bytes.NewReader(decodedValue), m.termWidth, m.termHeight) if err == nil { view = strings.TrimRight(img, "\n") } } if view == "" { view = lipgloss.NewStyle().Width(m.termWidth).Render(value) } m.previewValue = value m.previewSearchInput.SetValue("") m.previewSearchResults = nil m.previewSearchCursor = -1 m.preview.SetContent(view) m.preview.GotoTop() case key.Matches(msg, keyMap.Print): return m, m.print() case key.Matches(msg, keyMap.Open): return m, m.open() case key.Matches(msg, keyMap.GotoSymbol): m.gotoSymbolInput.CursorEnd() m.gotoSymbolInput.Width = m.termWidth - 2 // -1 for the prompt, -1 for the cursor m.gotoSymbolInput.Focus() m.createKeysIndex() case key.Matches(msg, keyMap.GotoRef): at, ok := m.cursorPointsTo() if !ok { return m, nil } value, isRef := isRefNode(at) if isRef { refPath, ok := jsonpath.ParseSchemaRef(value) if ok { m.selectNode(m.findByPath(refPath)) m.recordHistory() } } case key.Matches(msg, keyMap.CommandLine): m.commandInput.CursorEnd() m.commandInput.Width = m.termWidth - 2 // -1 for the prompt, -1 for the cursor m.commandInput.Focus() case key.Matches(msg, keyMap.Search): m.searchInput.CursorEnd() m.searchInput.Width = m.termWidth - 2 // -1 for the prompt, -1 for the cursor m.searchInput.Focus() case key.Matches(msg, keyMap.SearchNext): m.selectSearchResult(m.search.cursor + 1) m.recordHistory() case key.Matches(msg, keyMap.SearchPrev): m.selectSearchResult(m.search.cursor - 1) m.recordHistory() case key.Matches(msg, keyMap.GoBack): if m.locationIndex > 0 { at, ok := m.cursorPointsTo() if !ok { return m, nil } m.locationIndex-- loc := m.locationHistory[m.locationIndex] for loc.node == at && m.locationIndex > 0 { m.locationIndex-- loc = m.locationHistory[m.locationIndex] } m.selectNode(loc.head) m.selectNode(loc.node) } case key.Matches(msg, keyMap.GoForward): if m.locationIndex < len(m.locationHistory)-1 { m.locationIndex++ loc := m.locationHistory[m.locationIndex] m.selectNode(loc.head) m.selectNode(loc.node) } case key.Matches(msg, keyMap.Delete): m.deletePending = true } return m, nil } func (m *model) up() { if m.head == nil { return } m.showCursor = true m.cursor-- if m.cursor < 0 { m.cursor = 0 if m.head.Prev != nil { m.head = m.head.Prev } } } func (m *model) down() { if m.head == nil { return } m.showCursor = true m.cursor++ _, ok := m.cursorPointsTo() if !ok { m.cursor-- return } if m.cursor >= m.viewHeight() { m.cursor = m.viewHeight() - 1 if m.head.Next != nil { m.head = m.head.Next } } } func (m *model) recordHistory() { at, ok := m.cursorPointsTo() if !ok { return } if at.Chunk != "" && at.Value == "" { // We at the wrapped string, save the location of the original string node. at = at.Parent } if len(m.locationHistory) > 0 && m.locationHistory[len(m.locationHistory)-1].node == at { return } if m.locationIndex < len(m.locationHistory) { m.locationHistory = m.locationHistory[:m.locationIndex+1] } m.locationHistory = append(m.locationHistory, location{ head: m.head, node: at, }) m.locationIndex = len(m.locationHistory) } func (m *model) scrollToBottom() { if m.bottom == nil { return } m.head = m.bottom.Bottom() m.cursor = 0 m.showCursor = true m.scrollIntoView() } func (m *model) visibleLines() int { visibleLines := 0 n := m.head for n != nil && visibleLines < m.viewHeight() { visibleLines++ n = n.Next } return visibleLines } func (m *model) scrollIntoView() { if m.head == nil { return } visibleLines := m.visibleLines() if m.cursor >= visibleLines { m.cursor = visibleLines - 1 } for visibleLines < m.viewHeight() && m.head.Prev != nil { visibleLines++ m.cursor++ m.head = m.head.Prev } } func (m *model) scrollBackward(lines int) { it := m.head for it.Prev != nil { it = it.Prev if lines--; lines == 0 { break } } m.head = it } func (m *model) scrollForward(lines int) { if m.head == nil { return } it := m.head for it.Next != nil { it = it.Next if lines--; lines == 0 { break } } m.head = it } func (m *model) prettyKey(node *Node, selected bool) []byte { b := node.Key style := theme.CurrentTheme.Key if selected { style = theme.CurrentTheme.Cursor } if indexes, ok := m.search.keys[node]; ok { var out []byte for i, p := range splitByIndexes(b, indexes) { if i%2 == 0 { out = append(out, style(p.b)...) } else if p.index == m.search.cursor { out = append(out, theme.CurrentTheme.Cursor(p.b)...) } else { out = append(out, theme.CurrentTheme.Search(p.b)...) } } return out } else { return []byte(style(b)) } } func (m *model) prettyPrint(node *Node, isSelected, isRef bool) string { var s string if node.Chunk != "" { s = node.Chunk } else { s = node.Value } if len(s) == 0 { if isSelected { return theme.CurrentTheme.Cursor(" ") } else { return s } } var style theme.Color if isSelected { style = theme.CurrentTheme.Cursor } else { style = theme.Value(node.Kind) } if isRef { style = theme.CurrentTheme.Ref } if indexes, ok := m.search.values[node]; ok { var out strings.Builder for i, p := range splitByIndexes(s, indexes) { if i%2 == 0 { out.WriteString(style(p.b)) } else if p.index == m.search.cursor { out.WriteString(theme.CurrentTheme.Cursor(p.b)) } else { out.WriteString(theme.CurrentTheme.Search(p.b)) } } return out.String() } else { return style(s) } } func (m *model) viewWidth() int { width := m.termWidth if m.showLineNumbers { width -= len(strconv.Itoa(m.totalLines)) width -= 2 // For margin between line numbers and JSON. } return width } func (m *model) viewHeight() int { if m.gotoSymbolInput.Focused() { return m.termHeight - 2 } if m.commandInput.Focused() { return m.termHeight - 2 } if m.searchInput.Focused() || m.searchInput.Value() != "" { return m.termHeight - 2 } if m.yank { return m.termHeight - 2 } if m.showShowSelector { return m.termHeight - 2 } return m.termHeight - 1 } func (m *model) cursorPointsTo() (*Node, bool) { n := m.at(m.cursor) return n, n != nil } func (m *model) at(pos int) *Node { head := m.head for i := 0; i < pos; i++ { if head == nil { break } head = head.Next } return head } func (m *model) nodeInsideView(n *Node) bool { if n == nil { return false } head := m.head for i := 0; i < m.viewHeight(); i++ { if head == nil { break } if head == n { return true } head = head.Next } return false } func (m *model) selectNodeInView(n *Node) { head := m.head for i := 0; i < m.viewHeight(); i++ { if head == nil { break } if head == n { m.cursor = i return } head = head.Next } } func (m *model) selectNode(n *Node) { if n == nil { return } m.showCursor = true if m.nodeInsideView(n) { m.selectNodeInView(n) m.scrollIntoView() } else { m.cursor = 0 m.head = n { parent := n.Parent for parent != nil { parent.Expand() parent = parent.Parent } } m.centerLine(n) m.scrollIntoView() } } func (m *model) cursorPath() string { at, ok := m.cursorPointsTo() if !ok { return "" } path := "" for at != nil { if at.Prev != nil { if at.Chunk != "" && at.Value == "" { at = at.Parent } if at.Key != "" { quoted := at.Key unquoted, err := strconv.Unquote(quoted) if err == nil && jsonpath.Identifier.MatchString(unquoted) { path = "." + unquoted + path } else { path = "[" + quoted + "]" + path } } else if at.Index >= 0 { path = "[" + strconv.Itoa(at.Index) + "]" + path } } at = at.Parent } return path } func (m *model) cursorValue() string { at, ok := m.cursorPointsTo() if !ok { return "" } parent := at.Parent if parent != nil { // wrapped string part if at.Chunk != "" && at.Value == "" { at = parent } if len(at.Value) >= 1 && at.Value[0] == '}' || at.Value[0] == ']' { at = parent } } if at.Kind == String { str, err := strconv.Unquote(at.Value) if err == nil { return str } return at.Value } var out strings.Builder out.WriteString(at.Value) out.WriteString("\n") if at.HasChildren() { it := at.Next if at.IsCollapsed() { it = at.Collapsed } for it != nil { out.WriteString(strings.Repeat(" ", int(it.Depth-at.Depth))) if it.Key != "" { out.WriteString(it.Key) out.WriteString(": ") } if it.Value != "" { out.WriteString(it.Value) } if it == at.End { break } if it.Comma { out.WriteString(",") } out.WriteString("\n") if it.ChunkEnd != nil { it = it.ChunkEnd.Next } else if it.IsCollapsed() { it = it.Collapsed } else { it = it.Next } } } return out.String() } func (m *model) cursorKey() string { at, ok := m.cursorPointsTo() if !ok { return "" } if at.IsWrap() { at = at.Parent } if at.Key != "" { var v string _ = json.Unmarshal([]byte(at.Key), &v) return v } return strconv.Itoa(at.Index) } func (m *model) findByPath(path []any) *Node { n := m.currentTopNode() return n.FindByPath(path) } func (m *model) currentTopNode() *Node { at, ok := m.cursorPointsTo() if !ok { return nil } for at.Parent != nil { at = at.Parent } return at } func (m *model) createKeysIndex() { at, ok := m.cursorPointsTo() if !ok { return } root := at.Root() if root == nil { return } paths := make([]string, 0, 100_000) nodes := make([]*Node, 0, 100_000) root.Paths(&paths, &nodes) m.keysIndex = paths m.keysIndexNodes = nodes m.fuzzyMatch = nil } func (m *model) dig(v string) *Node { p, ok := jsonpath.Split(v) if !ok { return nil } at := m.findByPath(p) if at != nil { return at } lastPart := p[len(p)-1] searchTerm, ok := lastPart.(string) if !ok { return nil } p = p[:len(p)-1] at = m.findByPath(p) if at == nil { return nil } keys, nodes := at.Children() found := fuzzy.Find([]rune(searchTerm), keys) if found == nil { return nil } return nodes[found.Index] } func (m *model) print() tea.Cmd { m.printOnExit = true return tea.Quit } func (m *model) open() tea.Cmd { if engine.FilePath == "" { return nil } command := append( strings.Split(lookup([]string{"FX_EDITOR", "EDITOR"}, "vim"), " "), engine.FilePath, ) if command[0] == "vi" || command[0] == "vim" || command[0] == "hx" { at, ok := m.cursorPointsTo() if ok { tail := command[1:] command = append([]string{command[0]}, fmt.Sprintf("+%d", at.LineNumber)) command = append(command, tail...) } } execCmd := exec.Command(command[0], command[1:]...) return tea.ExecProcess(execCmd, func(err error) tea.Msg { return nil }) } // deleteAtCursor deletes the current key/value (node) from the view structure. func (m *model) deleteAtCursor() { at, ok := m.cursorPointsTo() if !ok || at == nil { return } if next, ok := DeleteNode(at); ok { m.selectNode(next) m.recordHistory() } } ================================================ FILE: main_test.go ================================================ package main import ( "bytes" "io" "os" "testing" "time" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/charmbracelet/x/exp/teatest" "github.com/muesli/termenv" "github.com/stretchr/testify/require" "github.com/antonmedv/fx/internal/jsonx" ) func init() { lipgloss.SetColorProfile(termenv.ANSI) } type options struct { showSizes bool showLineNumbers bool } func prepare(t *testing.T, opts ...options) *teatest.TestModel { file, err := os.Open("testdata/example.json") require.NoError(t, err) json, err := io.ReadAll(file) require.NoError(t, err) head, err := jsonx.Parse(json) require.NoError(t, err) m := &model{ top: head, head: head, bottom: head, totalLines: head.Bottom().LineNumber, eof: true, wrap: true, showCursor: true, searchInput: textinput.New(), search: newSearch(), commandInput: textinput.New(), } if len(opts) > 0 { m.showSizes = opts[0].showSizes m.showLineNumbers = opts[0].showLineNumbers } tm := teatest.NewTestModel( t, m, teatest.WithInitialTermSize(80, 40), ) return tm } func read(t *testing.T, tm *teatest.TestModel) []byte { var out []byte teatest.WaitFor(t, tm.Output(), func(b []byte) bool { out = b return bytes.Contains(b, []byte("{")) }, teatest.WithCheckInterval(time.Millisecond*100), teatest.WithDuration(time.Second), ) return out } func TestOutput(t *testing.T) { tm := prepare(t) teatest.RequireEqualOutput(t, read(t, tm)) tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("q")}) tm.WaitFinished(t, teatest.WithFinalTimeout(time.Second)) } func TestNavigation(t *testing.T) { tm := prepare(t) tm.Send(tea.KeyMsg{Type: tea.KeyDown}) tm.Send(tea.KeyMsg{Type: tea.KeyDown}) tm.Send(tea.KeyMsg{Type: tea.KeyDown}) teatest.RequireEqualOutput(t, read(t, tm)) tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("q")}) tm.WaitFinished(t, teatest.WithFinalTimeout(time.Second)) } func TestCollapseRecursive(t *testing.T) { tm := prepare(t) tm.Send(tea.KeyMsg{Type: tea.KeyShiftLeft}) teatest.RequireEqualOutput(t, read(t, tm)) tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("q")}) tm.WaitFinished(t, teatest.WithFinalTimeout(time.Second)) } func TestCollapseRecursiveWithSizes(t *testing.T) { tm := prepare(t, options{showSizes: true}) tm.Send(tea.KeyMsg{Type: tea.KeyShiftLeft}) teatest.RequireEqualOutput(t, read(t, tm)) tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("q")}) tm.WaitFinished(t, teatest.WithFinalTimeout(time.Second)) } ================================================ FILE: npm/README.md ================================================ # fx A non-interactive, JavaScript version of the [**fx**](https://fx.wtf). Short for _Function eXecution_ or _f(x)_. ```sh npm i -g fx ``` Or use **npx**: ```sh cat file.json | npx fx .field ``` Or use **deno**: ```sh cat file.json | deno run -A npm:fx .field ``` ## Usage Fx treats arguments as JavaScript functions. Fx passes the input data to the first function and then passes the result of the first function to the second function and so on. ```sh echo '{"name": "world"}' | fx 'x => x.name' 'x => `Hello, ${x}!`' ``` Use `this` to access the input data. Use `.` at the start of the expression to access the input data without a `x => x` part. ```sh echo '{"name": "world"}' | fx '.name' '`Hello, ${this}!`' ``` Use other JS functions to process the data. ```sh echo '{"name": "world"}' | fx 'Object.keys' ``` ### Stream processing Fx can process a stream of json objects. Fx will apply arguments to each object. ```sh echo '{"name": "hello"}\n{"name": "world"}' | fx '.name' ``` If you want to process a stream of json objects as a single array, use the **--slurp** or **-s** flag. ```sh echo '{"name": "hello"}\n{"name": "world"}' | fx --slurp '.map(x => x.name)' '.join(", ")' ``` ### Raw input If you want to process non-JSON data, use the **--raw** or **-r** flag. ```sh ls | fx -r '[this, this.includes(".md")]' ``` You can use **--raw** and **--slurp** (or **-rs**) together to get a single array of strings. ```sh ls | fx -rs '.filter(x => x.includes(".md"))' ``` Fx has a special symbol **skip** for skipping the printing of the result. ```sh ls | fx -r '.includes(".md") ? this : skip' ``` ### Built-in functions Fx comes with a set of useful functions: **uniq**, **sort**, **groupBy**, **chunk**, **zip**. ```sh cat file.json | fx 'uniq' 'sort' 'groupBy(x => x.name)' ``` ### Edit-in-place You can use special function **save** to edit-in-place the input data. ```sh fx file.json 'x.name = x.name.toUpperCase(), x' 'save' ``` The edited data will be saved to the same `file.json` file. ### Syntactic Sugar Fx has a shortcut for the map function. Fox example, `this.map(x => x.commit.message)` can be rewritten without leading dot and without `x => x` parts. ```sh curl https://api.github.com/repos/antonmedv/fx/commits | fx 'map(.commit.message)' ``` ```sh echo '[{"name": "world"}]' | fx 'map(`Hello, ${x.name}!`)' ``` Fx has a special syntax for the flatMap function. Fox example, `.issues.flatMap(x => x.labels.flatMap(x => x))` can be rewritten in the next way. ```sh curl https://fx.wtf/example.json | fx '.issues[].labels[]' ``` ### .fxrc.js Fx supports `.fxrc.js` file in the current directory, or in the home directory, or in XDG config directory. Put the next code in the `.fxrc.js` file to make `myFunction` available in the fx. ```js function addOne(x) { return x + 1 } ``` Now you can use `addOne` in the fx. ```sh echo '1' | fx addOne ``` If you would like to create global variables use `var` instead of `let` or `const`. ## License [MIT](../LICENSE) ================================================ FILE: npm/index.js ================================================ #!/usr/bin/env node 'use strict' void async function main() { const os = await import('node:os') const fs = await import('node:fs') const path = await import('node:path') const process = await import('node:process') let flagHelp = false let flagRaw = false let flagSlurp = false let flagYaml = false const args = [] for (const arg of process.argv.slice(2)) { if (arg === '--help' || arg === '-h') flagHelp = true else if (arg === '--raw' || arg === '-r') flagRaw = true else if (arg === '--slurp' || arg === '-s') flagSlurp = true else if (arg === '-rs' || arg === '-sr') flagRaw = flagSlurp = true else if (arg === '--yaml') flagYaml = true else args.push(arg) } if (flagHelp || (args.length === 0 && process.stdin.isTTY)) { return printUsage() } const theme = themes(process.stdout.isTTY ? (process.env.FX_THEME || '1') : '0') loadFxrc(os, fs, path, process) let fd = 0 // stdin if (args.length > 0) { let filename = isFile(fs, args[0]) ? args.shift() : isFile(fs, args.at(-1)) ? args.pop() : false if (filename) { globalThis.__file__ = filename fd = fs.openSync(filename, 'r') if (!flagYaml) flagYaml = /\.ya?ml$/i.test(filename) } } const gen = await read(fd) const input = flagRaw ? readLine(gen) : flagYaml ? parseYaml(gen) : parseJson(gen) if (flagSlurp) { const array = [] for (const json of input) { array.push(json) } await transform(array, args, theme) } else { for (const json of input) { await transform(json, args, theme) } } }() const skip = Symbol('skip') async function transform(json, args, theme) { let i, code, jsCode, output = json for ([i, code] of args.entries()) try { jsCode = transpile(code) const fn = `(function () { const x = this return ${jsCode} })` output = await run(output, fn) if (output === skip) break } catch (err) { await printErr(err) } if (typeof output === 'undefined') console.error('undefined') else if (typeof output === 'string') console.log(output) else if (output === skip) return else console.log(stringify(output, theme)) async function printErr(err) { const process = await import('node:process') let pre = args.slice(0, i).join(' ') let post = args.slice(i + 1).join(' ') if (pre.length > 20) pre = '...' + pre.substring(pre.length - 20) if (post.length > 20) post = post.substring(0, 20) + '...' console.error( `\n ${pre} ${code} ${post}\n` + ` ${' '.repeat(pre.length + 1)}${'^'.repeat(code.length)}\n` + (jsCode !== code ? `\n${jsCode}\n` : ``) + `\n${err.stack || err}`, ) process.exit(1) } } function transpile(code) { if ('.' === code) return 'x' if (/^(\.\w*)+\[]/.test(code)) return `(${fold(code.split('[]'))})(x)` function fold(s) { if (s.length === 1) return 'x => x' + s[0] let obj = s.shift() obj = obj === '.' ? 'x' : 'x' + obj return `x => ${obj}.flatMap(${fold(s)})` } if (/^\.\[/.test(code)) return `x${code.substring(1)}` if (/^\./.test(code)) return `x${code}` if (/^@/.test(code)) { const jsCode = transpile(code.substring(1)) return `map((x, i) => apply(${jsCode}, x, i))` } if (/^\?/.test(code)) { const jsCode = transpile(code.substring(1)) return `filter((x, i) => apply(${jsCode}, x, i))` } return code } async function run(json, code) { const fs = await import('node:fs') const fn = eval(code).call(json) return apply(fn, json) function apply(fn, ...args) { if (typeof fn === 'function') return fn(...args) return fn } function len(x) { if (Array.isArray(x)) return x.length if (typeof x === 'string') return x.length if (typeof x === 'object' && x !== null) return Object.keys(x).length throw new Error(`Cannot get length of ${typeof x}`) } function uniq(x) { if (Array.isArray(x)) return [...new Set(x)] throw new Error(`Cannot get unique values of ${typeof x}`) } function sort(x) { if (Array.isArray(x)) return x.sort() throw new Error(`Cannot sort ${typeof x}`) } function isFalsely(x) { return x === false || x === null || x === undefined } function filter(fn) { return function (x) { if (Array.isArray(x)) { return x.filter((v, i) => !isFalsely(fn(v, i))) } return isFalsely(fn(x))? skip : x } } function map(fn) { return function (x) { if (Array.isArray(x)) { return x.map((v, i) => fn(v, i)) } return fn(x) } } function walk(fn) { return function recurse(value, key = null) { if (Array.isArray(value)) { const mapped = value.map((v, i) => recurse(v, i)) return fn(mapped, key) } else if (value !== null && typeof value === 'object') { const result = {} for (const [k, v] of Object.entries(value)) { result[k] = recurse(v, k) } return fn(result, key) } else { return fn(value, key) } } } function sortBy(fn) { return function (x) { if (Array.isArray(x)) return x.sort((a, b) => { const fa = fn(a) const fb = fn(b) return fa < fb ? -1 : fa > fb ? 1 : 0 }) throw new Error(`Cannot sort ${typeof x}`) } } function sortKeys(x) { if (Array.isArray(x)) { return x.map(sortKeys) } if (typeof x === 'object' && x !== null) { const sorted = {} for (const key of Object.keys(x).sort()) { sorted[key] = sortKeys(x[key]) } return sorted } return x } function groupBy(keyFn) { return function (x) { const grouped = {} for (const item of x) { const key = typeof keyFn === 'function' ? keyFn(item) : item[keyFn] if (!Object.prototype.hasOwnProperty.call(grouped, key)) grouped[key] = [] grouped[key].push(item) } return grouped } } function chunk(size) { return function (x) { const res = [] let i = 0 while (i < x.length) { res.push(x.slice(i, i += size)) } return res } } function zip(...x) { const length = Math.min(...x.map(a => a.length)) const res = [] for (let i = 0; i < length; i++) { res.push(x.map(a => a[i])) } return res } function flatten(x) { if (Array.isArray(x)) return x.flat() throw new Error(`Cannot flatten ${typeof x}`) } function reverse(x) { if (Array.isArray(x)) return x.reverse() throw new Error(`Cannot reverse ${typeof x}`) } function keys(x) { if (typeof x === 'object' && x !== null) return Object.keys(x) throw new Error(`Cannot get keys of ${typeof x}`) } function values(x) { if (typeof x === 'object' && x !== null) return Object.values(x) throw new Error(`Cannot get values of ${typeof x}`) } function list(x) { if (Array.isArray(x)) { for (const y of x) console.log(y) return skip } throw new Error(`Cannot list ${typeof x}`) } function del(key) { return function (x) { if (Array.isArray(x)) { const copy = [...x] copy.splice(key, 1) return copy } if (typeof x === 'object' && x !== null) { const copy = {...x} delete copy[key] return copy } throw new Error(`Cannot delete key from ${typeof x}`) } } function exit(code) { process.exit(code) } function save(x) { if (!globalThis.__file__) throw new Error('Specify a file as the first argument to be able to save: fx file.json ...') fs.writeFileSync(globalThis.__file__, JSON.stringify(x, null, 2)) return x } function toBase64(x) { return Buffer.from(x).toString('base64') } function fromBase64(x) { return Buffer.from(x, 'base64').toString() } } async function read(fd = 0) { const fs = await import('node:fs') const {Buffer} = await import('node:buffer') const {StringDecoder} = await import('node:string_decoder') const decoder = new StringDecoder('utf8') return function* () { while (true) { const buffer = Buffer.alloc(4_096) let bytesRead try { bytesRead = fs.readSync(fd, buffer, 0, buffer.length, null) } catch (e) { if (e.code === 'EAGAIN' || e.code === 'EWOULDBLOCK') { sleepSync(10) continue } if (e.code === 'EOF') break throw e } if (bytesRead === 0) break for (const ch of decoder.write(buffer.subarray(0, bytesRead))) yield ch } for (const ch of decoder.end()) yield ch }() } function isFile(fs, path) { try { const stat = fs.statSync(path, {throwIfNoEntry: false}) return stat !== undefined && stat.isFile() } catch (err) { return false } } function sleepSync(ms) { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms) } function* readLine(stdin) { let buffer = '' for (const ch of stdin) { if (ch === '\n') { yield buffer buffer = '' } else { buffer += ch } } if (buffer.length > 0) yield buffer return buffer } function* parseYaml(gen) { let buffer = '' for (const ch of gen) { buffer += ch } try { yield YAML.parse(buffer) } catch (err) { throw new SyntaxError(err.message) } } function* parseJson(gen) { let lineNumber = 1, buffer = '', lastChar, done = false function next() { ({value: lastChar, done} = gen.next()) if (lastChar === '\n') lineNumber++ buffer += (lastChar || '') if (buffer.length > 100) buffer = buffer.slice(-40) } next() while (!done) { const value = parseValue() expectValue(value) yield value } function parseValue() { skipWhitespace() const value = parseString() ?? parseNumber() ?? parseObject() ?? parseArray() ?? parseKeyword('true', true) ?? parseKeyword('false', false) ?? parseKeyword('null', null) skipWhitespace() return value } function parseString() { if (lastChar !== '"') return let str = '' let escaped = false while (true) { next() if (escaped) { if (lastChar === 'u') { let unicode = '' for (let i = 0; i < 4; i++) { next() if (!isHexDigit(lastChar)) { throw new SyntaxError(errorSnippet(`Invalid Unicode escape sequence '\\u${unicode}${lastChar}'`)) } unicode += lastChar } str += String.fromCharCode(parseInt(unicode, 16)) } else { const escapedChar = { '"': '"', '\\': '\\', '/': '/', 'b': '\b', 'f': '\f', 'n': '\n', 'r': '\r', 't': '\t', }[lastChar] if (!escapedChar) { throw new SyntaxError(errorSnippet()) } str += escapedChar } escaped = false } else if (lastChar === '\\') { escaped = true } else if (lastChar === '"') { break } else if (lastChar < '\x1F') { throw new SyntaxError(errorSnippet(`Unescaped control character ${JSON.stringify(lastChar)}`)) } else if (lastChar === undefined) { throw new SyntaxError(errorSnippet()) } else { str += lastChar } } next() return str } function parseNumber() { if (!isDigit(lastChar) && lastChar !== '-') return let numStr = '' if (lastChar === '-') { numStr += lastChar next() if (!isDigit(lastChar)) { throw new SyntaxError(errorSnippet()) } } if (lastChar === '0') { numStr += lastChar next() } else { while (isDigit(lastChar)) { numStr += lastChar next() } } if (lastChar === '.') { numStr += lastChar next() if (!isDigit(lastChar)) { throw new SyntaxError(errorSnippet()) } while (isDigit(lastChar)) { numStr += lastChar next() } } if (lastChar === 'e' || lastChar === 'E') { numStr += lastChar next() if (lastChar === '+' || lastChar === '-') { numStr += lastChar next() } if (!isDigit(lastChar)) { throw new SyntaxError(errorSnippet()) } while (isDigit(lastChar)) { numStr += lastChar next() } } return isInteger(numStr) ? toSafeNumber(numStr) : parseFloat(numStr) } function parseObject() { if (lastChar !== '{') return next() skipWhitespace() const obj = {} if (lastChar === '}') { next() return obj } while (true) { if (lastChar !== '"') { throw new SyntaxError(errorSnippet()) } const key = parseString() skipWhitespace() if (lastChar !== ':') { throw new SyntaxError(errorSnippet()) } next() const value = parseValue() expectValue(value) obj[key] = value skipWhitespace() if (lastChar === '}') { next() return obj } else if (lastChar === ',') { next() skipWhitespace() if (lastChar === '}') { next() return obj } } else { throw new SyntaxError(errorSnippet()) } } } function parseArray() { if (lastChar !== '[') return next() skipWhitespace() const array = [] if (lastChar === ']') { next() return array } while (true) { const value = parseValue() expectValue(value) array.push(value) skipWhitespace() if (lastChar === ']') { next() return array } else if (lastChar === ',') { next() skipWhitespace() if (lastChar === ']') { next() return array } } else { throw new SyntaxError(errorSnippet()) } } } function parseKeyword(name, value) { if (lastChar !== name[0]) return for (let i = 1; i < name.length; i++) { next() if (lastChar !== name[i]) { throw new SyntaxError(errorSnippet()) } } next() if (isWhitespace(lastChar) || lastChar === ',' || lastChar === '}' || lastChar === ']' || lastChar === undefined) { return value } throw new SyntaxError(errorSnippet()) } function skipWhitespace() { while (isWhitespace(lastChar)) { next() } skipComment() } function skipComment() { if (lastChar === '/') { next() if (lastChar === '/') { while (!done && lastChar !== '\n') { next() } skipWhitespace() } else if (lastChar === '*') { while (!done) { next() if (lastChar === '*') { next() if (lastChar === '/') { next() break } } } skipWhitespace() } else { throw new SyntaxError(errorSnippet()) } } } function isWhitespace(ch) { return ch === ' ' || ch === '\n' || ch === '\t' || ch === '\r' } function isHexDigit(ch) { return (ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f') || (ch >= 'A' && ch <= 'F') } function isDigit(ch) { return ch >= '0' && ch <= '9' } function isInteger(value) { return /^-?[0-9]+$/.test(value) } function toSafeNumber(str) { const maxSafeInteger = Number.MAX_SAFE_INTEGER const minSafeInteger = Number.MIN_SAFE_INTEGER const num = BigInt(str) return num >= minSafeInteger && num <= maxSafeInteger ? Number(num) : num } function expectValue(value) { if (value === undefined) { throw new SyntaxError(errorSnippet(`JSON value expected`)) } } function errorSnippet(message = `Unexpected character '${lastChar}'`) { if (!lastChar) { message = 'Unexpected end of input' } const lines = buffer.slice(-40).split('\n') const lastLine = lines.pop() const source = lines.map(line => ` ${line}\n`).join('') + ` ${lastLine}${readEOL()}\n` const p = ` ${'.'.repeat(Math.max(0, lastLine.length - 1))}^\n` return `${message} on line ${lineNumber}.\n\n${source}${p}` } function readEOL() { let line = '' for (const ch of gen) { if (!ch || ch === '\n' || line.length >= 60) break line += ch } return line } } function stringify(value, theme) { function color(id, str) { if (theme[id] === '') return str return `\x1b[${theme[id]}m${str}\x1b[0m` } function getIndent(level) { return ' '.repeat(2 * level) } function stringifyValue(value, level = 0) { if (typeof value === 'string') { return color(2, JSON.stringify(value)) } else if (typeof value === 'number') { return color(3, `${value}`) } else if (typeof value === 'bigint') { return color(3, `${value}`) } else if (typeof value === 'boolean') { return color(4, `${value}`) } else if (value === null || typeof value === 'undefined') { return color(5, `null`) } else if (Array.isArray(value)) { if (value.length === 0) { return color(0, `[]`) } const items = value .map((v) => getIndent(level + 1) + stringifyValue(v, level + 1)) .join(color(0, ',') + '\n') return color(0, '[') + '\n' + items + '\n' + getIndent(level) + color(0, ']') } else if (typeof value === 'object') { const keys = Object.keys(value) if (keys.length === 0) { return color(0, '{}') } const entries = keys .map((key) => getIndent(level + 1) + color(1, `"${key}"`) + color(0, ': ') + stringifyValue(value[key], level + 1), ) .join(color(0, ',') + '\n') return color(0, '{') + '\n' + entries + '\n' + getIndent(level) + color(0, '}') } throw new Error(`Unsupported value type: ${typeof value}`) } return stringifyValue(value) } function themes(id) { const themes = { '0': ['', '', '', '', '', ''], '1': ['', '1;34', '32', '36', '35', '38;5;243'], '2': ['', '32', '34', '36', '35', '38;5;243'], '3': ['', '95', '93', '96', '31', '38;5;243'], '4': ['', '38;5;50', '38;5;39', '38;5;98', '38;5;205', '38;5;243'], '5': ['', '38;5;230', '38;5;221', '38;5;209', '38;5;209', '38;5;243'], '6': ['', '38;5;69', '38;5;78', '38;5;221', '38;5;203', '38;5;243'], '7': ['', '1;38;5;42', '1;38;5;213', '1;38;5;201', '1;38;5;201', '38;5;243'], '8': ['', '1;38;5;51', '38;5;195', '38;5;123', '38;5;50', '38;5;243'], '9': ['', '1;38;5;39', '38;5;49', '38;5;220', '38;5;205', '38;5;243'], '🔥': ['1;38;5;208', '1;38;5;202', '38;5;214', '38;5;202', '38;5;196', '38;5;243'], '🔵': ['1;38;5;33', '38;5;33', '', '', '', ''], '🟣': ['', '1;38;5;141', '38;5;183', '38;5;219', '38;5;81', '38;5;243'], '🥝': ['38;5;179', '1;38;5;154', '38;5;82', '38;5;226', '38;5;226', '38;5;230'], } return themes[id] || themes['1'] } async function importFxrc(path) { const {join} = await import('node:path') const {pathToFileURL} = await import('node:url') try { await import(pathToFileURL(join(path, '.fxrc.js'))) } catch (err) { if (err.code !== 'ERR_MODULE_NOT_FOUND') throw err } } function loadFxrc(os, fs, path, process) { let script = '' const cwd = process.cwd() const home = os.homedir() const xdgHome = process.env.XDG_CONFIG_HOME || path.join(home, '.config') const xdgDirsEnv = process.env.XDG_CONFIG_DIRS || '/etc/xdg' const paths = [path.join(cwd, '.fxrc.js')] paths.push(path.join(home, '.fxrc.js')) paths.push(path.join(xdgHome, 'fx', '.fxrc.js')) for (const dir of xdgDirsEnv.split(':')) { paths.push(path.join(dir, 'fx', '.fxrc.js')) } for (const filePath of paths) { try { const stat = fs.statSync(filePath) if (stat.isDirectory()) continue const data = fs.readFileSync(filePath, 'utf8') script += data + '\n' } catch (err) { if (err.code === 'ENOENT') continue throw new Error(`read ${filePath}: ${err.message}`) } } eval?.(script) } function printUsage() { const usage = `Usage fx [flags] [code...] Flags -h, --help print help -r, --raw treat input as a raw string -s, --slurp read all inputs into an array --yaml parse input as YAML` console.log(usage) } // yaml v2.4.0 // @formatter:off void function () {var ALIAS=Symbol.for("yaml.alias");var DOC=Symbol.for("yaml.document");var MAP=Symbol.for("yaml.map");var PAIR=Symbol.for("yaml.pair");var SCALAR=Symbol.for("yaml.scalar");var SEQ=Symbol.for("yaml.seq");var NODE_TYPE=Symbol.for("yaml.node.type");var isAlias=node=>!!node&&typeof node==="object"&&node[NODE_TYPE]===ALIAS;var isDocument=node=>!!node&&typeof node==="object"&&node[NODE_TYPE]===DOC;var isMap=node=>!!node&&typeof node==="object"&&node[NODE_TYPE]===MAP;var isPair=node=>!!node&&typeof node==="object"&&node[NODE_TYPE]===PAIR;var isScalar=node=>!!node&&typeof node==="object"&&node[NODE_TYPE]===SCALAR;var isSeq=node=>!!node&&typeof node==="object"&&node[NODE_TYPE]===SEQ;function isCollection(node){if(node&&typeof node==="object")switch(node[NODE_TYPE]){case MAP:case SEQ:return true}return false}function isNode(node){if(node&&typeof node==="object")switch(node[NODE_TYPE]){case ALIAS:case MAP:case SCALAR:case SEQ:return true}return false}var hasAnchor=node=>(isScalar(node)||isCollection(node))&&!!node.anchor;var BREAK=Symbol("break visit");var SKIP=Symbol("skip children");var REMOVE=Symbol("remove node");function visit(node,visitor){const visitor_=initVisitor(visitor);if(isDocument(node)){const cd=visit_(null,node.contents,visitor_,Object.freeze([node]));if(cd===REMOVE)node.contents=null}else visit_(null,node,visitor_,Object.freeze([]))}visit.BREAK=BREAK;visit.SKIP=SKIP;visit.REMOVE=REMOVE;function visit_(key,node,visitor,path){const ctrl=callVisitor(key,node,visitor,path);if(isNode(ctrl)||isPair(ctrl)){replaceNode(key,path,ctrl);return visit_(key,ctrl,visitor,path)}if(typeof ctrl!=="symbol"){if(isCollection(node)){path=Object.freeze(path.concat(node));for(let i=0;itn.replace(/[!,[\]{}]/g,ch=>escapeChars[ch]);var Directives=class _Directives{constructor(yaml,tags){this.docStart=null;this.docEnd=false;this.yaml=Object.assign({},_Directives.defaultYaml,yaml);this.tags=Object.assign({},_Directives.defaultTags,tags)}clone(){const copy=new _Directives(this.yaml,this.tags);copy.docStart=this.docStart;return copy}atDocument(){const res=new _Directives(this.yaml,this.tags);switch(this.yaml.version){case"1.1":this.atNextDocument=true;break;case"1.2":this.atNextDocument=false;this.yaml={explicit:_Directives.defaultYaml.explicit,version:"1.2"};this.tags=Object.assign({},_Directives.defaultTags);break}return res}add(line,onError){if(this.atNextDocument){this.yaml={explicit:_Directives.defaultYaml.explicit,version:"1.1"};this.tags=Object.assign({},_Directives.defaultTags);this.atNextDocument=false}const parts=line.trim().split(/[ \t]+/);const name=parts.shift();switch(name){case"%TAG":{if(parts.length!==2){onError(0,"%TAG directive should contain exactly two parts");if(parts.length<2)return false}const[handle,prefix]=parts;this.tags[handle]=prefix;return true}case"%YAML":{this.yaml.explicit=true;if(parts.length!==1){onError(0,"%YAML directive should contain exactly one part");return false}const[version]=parts;if(version==="1.1"||version==="1.2"){this.yaml.version=version;return true}else{const isValid=/^\d+\.\d+$/.test(version);onError(6,`Unsupported YAML version ${version}`,isValid);return false}}default:onError(0,`Unknown directive ${name}`,true);return false}}tagName(source,onError){if(source==="!")return"!";if(source[0]!=="!"){onError(`Not a valid tag: ${source}`);return null}if(source[1]==="<"){const verbatim=source.slice(2,-1);if(verbatim==="!"||verbatim==="!!"){onError(`Verbatim tags aren't resolved, so ${source} is invalid.`);return null}if(source[source.length-1]!==">")onError("Verbatim tags must end with a >");return verbatim}const[,handle,suffix]=source.match(/^(.*!)([^!]*)$/s);if(!suffix)onError(`The ${source} tag has no suffix`);const prefix=this.tags[handle];if(prefix){try{return prefix+decodeURIComponent(suffix)}catch(error){onError(String(error));return null}}if(handle==="!")return source;onError(`Could not resolve tag: ${source}`);return null}tagString(tag){for(const[handle,prefix]of Object.entries(this.tags)){if(tag.startsWith(prefix))return handle+escapeTagName(tag.substring(prefix.length))}return tag[0]==="!"?tag:`!<${tag}>`}toString(doc){const lines=this.yaml.explicit?[`%YAML ${this.yaml.version||"1.2"}`]:[];const tagEntries=Object.entries(this.tags);let tagNames;if(doc&&tagEntries.length>0&&isNode(doc.contents)){const tags={};visit(doc.contents,(_key,node)=>{if(isNode(node)&&node.tag)tags[node.tag]=true});tagNames=Object.keys(tags)}else tagNames=[];for(const[handle,prefix]of tagEntries){if(handle==="!!"&&prefix==="tag:yaml.org,2002:")continue;if(!doc||tagNames.some(tn=>tn.startsWith(prefix)))lines.push(`%TAG ${handle} ${prefix}`)}return lines.join("\n")}};Directives.defaultYaml={explicit:false,version:"1.2"};Directives.defaultTags={"!!":"tag:yaml.org,2002:"};function anchorIsValid(anchor){if(/[\x00-\x19\s,[\]{}]/.test(anchor)){const sa=JSON.stringify(anchor);const msg=`Anchor must not contain whitespace or control characters: ${sa}`;throw new Error(msg)}return true}function anchorNames(root){const anchors=new Set;visit(root,{Value(_key,node){if(node.anchor)anchors.add(node.anchor)}});return anchors}function findNewAnchor(prefix,exclude){for(let i=1;true;++i){const name=`${prefix}${i}`;if(!exclude.has(name))return name}}function createNodeAnchors(doc,prefix){const aliasObjects=[];const sourceObjects=new Map;let prevAnchors=null;return{onAnchor:source=>{aliasObjects.push(source);if(!prevAnchors)prevAnchors=anchorNames(doc);const anchor=findNewAnchor(prefix,prevAnchors);prevAnchors.add(anchor);return anchor},setAnchors:()=>{for(const source of aliasObjects){const ref=sourceObjects.get(source);if(typeof ref==="object"&&ref.anchor&&(isScalar(ref.node)||isCollection(ref.node))){ref.node.anchor=ref.anchor}else{const error=new Error("Failed to resolve repeated object (this should not happen)");error.source=source;throw error}}},sourceObjects}}function applyReviver(reviver,obj,key,val){if(val&&typeof val==="object"){if(Array.isArray(val)){for(let i=0,len=val.length;itoJS(v,String(i),ctx));if(value&&typeof value.toJSON==="function"){if(!ctx||!hasAnchor(value))return value.toJSON(arg,ctx);const data={aliasCount:0,count:1,res:void 0};ctx.anchors.set(value,data);ctx.onCreate=res2=>{data.res=res2;delete ctx.onCreate};const res=value.toJSON(arg,ctx);if(ctx.onCreate)ctx.onCreate(res);return res}if(typeof value==="bigint"&&!ctx?.keep)return Number(value);return value}var NodeBase=class{constructor(type){Object.defineProperty(this,NODE_TYPE,{value:type})}clone(){const copy=Object.create(Object.getPrototypeOf(this),Object.getOwnPropertyDescriptors(this));if(this.range)copy.range=this.range.slice();return copy}toJS(doc,{mapAsMap,maxAliasCount,onAnchor,reviver}={}){if(!isDocument(doc))throw new TypeError("A document argument is required");const ctx={anchors:new Map,doc,keep:true,mapAsMap:mapAsMap===true,mapKeyWarned:false,maxAliasCount:typeof maxAliasCount==="number"?maxAliasCount:100};const res=toJS(this,"",ctx);if(typeof onAnchor==="function")for(const{count,res:res2}of ctx.anchors.values())onAnchor(res2,count);return typeof reviver==="function"?applyReviver(reviver,{"":res},"",res):res}};var Alias=class extends NodeBase{constructor(source){super(ALIAS);this.source=source;Object.defineProperty(this,"tag",{set(){throw new Error("Alias nodes cannot have tags")}})}resolve(doc){let found=void 0;visit(doc,{Node:(_key,node)=>{if(node===this)return visit.BREAK;if(node.anchor===this.source)found=node}});return found}toJSON(_arg,ctx){if(!ctx)return{source:this.source};const{anchors,doc,maxAliasCount}=ctx;const source=this.resolve(doc);if(!source){const msg=`Unresolved alias (the anchor must be set before the alias): ${this.source}`;throw new ReferenceError(msg)}let data=anchors.get(source);if(!data){toJS(source,null,ctx);data=anchors.get(source)}if(!data||data.res===void 0){const msg="This should not happen: Alias anchor was not resolved?";throw new ReferenceError(msg)}if(maxAliasCount>=0){data.count+=1;if(data.aliasCount===0)data.aliasCount=getAliasCount(doc,source,anchors);if(data.count*data.aliasCount>maxAliasCount){const msg="Excessive alias count indicates a resource exhaustion attack";throw new ReferenceError(msg)}}return data.res}toString(ctx,_onComment,_onChompKeep){const src=`*${this.source}`;if(ctx){anchorIsValid(this.source);if(ctx.options.verifyAliasOrder&&!ctx.anchors.has(this.source)){const msg=`Unresolved alias (the anchor must be set before the alias): ${this.source}`;throw new Error(msg)}if(ctx.implicitKey)return`${src} `}return src}};function getAliasCount(doc,node,anchors){if(isAlias(node)){const source=node.resolve(doc);const anchor=anchors&&source&&anchors.get(source);return anchor?anchor.count*anchor.aliasCount:0}else if(isCollection(node)){let count=0;for(const item of node.items){const c=getAliasCount(doc,item,anchors);if(c>count)count=c}return count}else if(isPair(node)){const kc=getAliasCount(doc,node.key,anchors);const vc=getAliasCount(doc,node.value,anchors);return Math.max(kc,vc)}return 1}var isScalarValue=value=>!value||typeof value!=="function"&&typeof value!=="object";var Scalar=class extends NodeBase{constructor(value){super(SCALAR);this.value=value}toJSON(arg,ctx){return ctx?.keep?this.value:toJS(this.value,arg,ctx)}toString(){return String(this.value)}};Scalar.BLOCK_FOLDED="BLOCK_FOLDED";Scalar.BLOCK_LITERAL="BLOCK_LITERAL";Scalar.PLAIN="PLAIN";Scalar.QUOTE_DOUBLE="QUOTE_DOUBLE";Scalar.QUOTE_SINGLE="QUOTE_SINGLE";var defaultTagPrefix="tag:yaml.org,2002:";function findTagObject(value,tagName,tags){if(tagName){const match=tags.filter(t=>t.tag===tagName);const tagObj=match.find(t=>!t.format)??match[0];if(!tagObj)throw new Error(`Tag ${tagName} not found`);return tagObj}return tags.find(t=>t.identify?.(value)&&!t.format)}function createNode(value,tagName,ctx){if(isDocument(value))value=value.contents;if(isNode(value))return value;if(isPair(value)){const map2=ctx.schema[MAP].createNode?.(ctx.schema,null,ctx);map2.items.push(value);return map2}if(value instanceof String||value instanceof Number||value instanceof Boolean||typeof BigInt!=="undefined"&&value instanceof BigInt){value=value.valueOf()}const{aliasDuplicateObjects,onAnchor,onTagObj,schema:schema4,sourceObjects}=ctx;let ref=void 0;if(aliasDuplicateObjects&&value&&typeof value==="object"){ref=sourceObjects.get(value);if(ref){if(!ref.anchor)ref.anchor=onAnchor(value);return new Alias(ref.anchor)}else{ref={anchor:null,node:null};sourceObjects.set(value,ref)}}if(tagName?.startsWith("!!"))tagName=defaultTagPrefix+tagName.slice(2);let tagObj=findTagObject(value,tagName,schema4.tags);if(!tagObj){if(value&&typeof value.toJSON==="function"){value=value.toJSON()}if(!value||typeof value!=="object"){const node2=new Scalar(value);if(ref)ref.node=node2;return node2}tagObj=value instanceof Map?schema4[MAP]:Symbol.iterator in Object(value)?schema4[SEQ]:schema4[MAP]}if(onTagObj){onTagObj(tagObj);delete ctx.onTagObj}const node=tagObj?.createNode?tagObj.createNode(ctx.schema,value,ctx):typeof tagObj?.nodeClass?.from==="function"?tagObj.nodeClass.from(ctx.schema,value,ctx):new Scalar(value);if(tagName)node.tag=tagName;else if(!tagObj.default)node.tag=tagObj.tag;if(ref)ref.node=node;return node}function collectionFromPath(schema4,path,value){let v=value;for(let i=path.length-1;i>=0;--i){const k=path[i];if(typeof k==="number"&&Number.isInteger(k)&&k>=0){const a=[];a[k]=v;v=a}else{v=new Map([[k,v]])}}return createNode(v,void 0,{aliasDuplicateObjects:false,keepUndefined:false,onAnchor:()=>{throw new Error("This should not happen, please report a bug.")},schema:schema4,sourceObjects:new Map})}var isEmptyPath=path=>path==null||typeof path==="object"&&!!path[Symbol.iterator]().next().done;var Collection=class extends NodeBase{constructor(type,schema4){super(type);Object.defineProperty(this,"schema",{value:schema4,configurable:true,enumerable:false,writable:true})}clone(schema4){const copy=Object.create(Object.getPrototypeOf(this),Object.getOwnPropertyDescriptors(this));if(schema4)copy.schema=schema4;copy.items=copy.items.map(it=>isNode(it)||isPair(it)?it.clone(schema4):it);if(this.range)copy.range=this.range.slice();return copy}addIn(path,value){if(isEmptyPath(path))this.add(value);else{const[key,...rest]=path;const node=this.get(key,true);if(isCollection(node))node.addIn(rest,value);else if(node===void 0&&this.schema)this.set(key,collectionFromPath(this.schema,rest,value));else throw new Error(`Expected YAML collection at ${key}. Remaining path: ${rest}`)}}deleteIn(path){const[key,...rest]=path;if(rest.length===0)return this.delete(key);const node=this.get(key,true);if(isCollection(node))return node.deleteIn(rest);else throw new Error(`Expected YAML collection at ${key}. Remaining path: ${rest}`)}getIn(path,keepScalar){const[key,...rest]=path;const node=this.get(key,true);if(rest.length===0)return!keepScalar&&isScalar(node)?node.value:node;else return isCollection(node)?node.getIn(rest,keepScalar):void 0}hasAllNullValues(allowScalar){return this.items.every(node=>{if(!isPair(node))return false;const n=node.value;return n==null||allowScalar&&isScalar(n)&&n.value==null&&!n.commentBefore&&!n.comment&&!n.tag})}hasIn(path){const[key,...rest]=path;if(rest.length===0)return this.has(key);const node=this.get(key,true);return isCollection(node)?node.hasIn(rest):false}setIn(path,value){const[key,...rest]=path;if(rest.length===0){this.set(key,value)}else{const node=this.get(key,true);if(isCollection(node))node.setIn(rest,value);else if(node===void 0&&this.schema)this.set(key,collectionFromPath(this.schema,rest,value));else throw new Error(`Expected YAML collection at ${key}. Remaining path: ${rest}`)}}};Collection.maxFlowStringSingleLineLength=60;var stringifyComment=str=>str.replace(/^(?!$)(?: $)?/gm,"#");function indentComment(comment,indent){if(/^\n+$/.test(comment))return comment.substring(1);return indent?comment.replace(/^(?! *$)/gm,indent):comment}var lineComment=(str,indent,comment)=>str.endsWith("\n")?indentComment(comment,indent):comment.includes("\n")?"\n"+indentComment(comment,indent):(str.endsWith(" ")?"":" ")+comment;var FOLD_FLOW="flow";var FOLD_BLOCK="block";var FOLD_QUOTED="quoted";function foldFlowLines(text,indent,mode="flow",{indentAtStart,lineWidth=80,minContentWidth=20,onFold,onOverflow}={}){if(!lineWidth||lineWidth<0)return text;const endStep=Math.max(1+minContentWidth,1+lineWidth-indent.length);if(text.length<=endStep)return text;const folds=[];const escapedFolds={};let end=lineWidth-indent.length;if(typeof indentAtStart==="number"){if(indentAtStart>lineWidth-Math.max(2,minContentWidth))folds.push(0);else end=lineWidth-indentAtStart}let split=void 0;let prev=void 0;let overflow=false;let i=-1;let escStart=-1;let escEnd=-1;if(mode===FOLD_BLOCK){i=consumeMoreIndentedLines(text,i);if(i!==-1)end=i+endStep}for(let ch;ch=text[i+=1];){if(mode===FOLD_QUOTED&&ch==="\\"){escStart=i;switch(text[i+1]){case"x":i+=3;break;case"u":i+=5;break;case"U":i+=9;break;default:i+=1}escEnd=i}if(ch==="\n"){if(mode===FOLD_BLOCK)i=consumeMoreIndentedLines(text,i);end=i+endStep;split=void 0}else{if(ch===" "&&prev&&prev!==" "&&prev!=="\n"&&prev!==" "){const next=text[i+1];if(next&&next!==" "&&next!=="\n"&&next!==" ")split=i}if(i>=end){if(split){folds.push(split);end=split+endStep;split=void 0}else if(mode===FOLD_QUOTED){while(prev===" "||prev===" "){prev=ch;ch=text[i+=1];overflow=true}const j=i>escEnd+1?i-2:escStart-1;if(escapedFolds[j])return text;folds.push(j);escapedFolds[j]=true;end=j+endStep;split=void 0}else{overflow=true}}}prev=ch}if(overflow&&onOverflow)onOverflow();if(folds.length===0)return text;if(onFold)onFold();let res=text.slice(0,folds[0]);for(let i2=0;i2({indentAtStart:isBlock2?ctx.indent.length:ctx.indentAtStart,lineWidth:ctx.options.lineWidth,minContentWidth:ctx.options.minContentWidth});var containsDocumentMarker=str=>/^(%|---|\.\.\.)/m.test(str);function lineLengthOverLimit(str,lineWidth,indentLength){if(!lineWidth||lineWidth<0)return false;const limit=lineWidth-indentLength;const strLen=str.length;if(strLen<=limit)return false;for(let i=0,start=0;ilimit)return true;start=i+1;if(strLen-start<=limit)return false}}return true}function doubleQuotedString(value,ctx){const json=JSON.stringify(value);if(ctx.options.doubleQuotedAsJSON)return json;const{implicitKey}=ctx;const minMultiLineLength=ctx.options.doubleQuotedMinMultiLineLength;const indent=ctx.indent||(containsDocumentMarker(value)?" ":"");let str="";let start=0;for(let i=0,ch=json[i];ch;ch=json[++i]){if(ch===" "&&json[i+1]==="\\"&&json[i+2]==="n"){str+=json.slice(start,i)+"\\ ";i+=1;start=i;ch="\\"}if(ch==="\\")switch(json[i+1]){case"u":{str+=json.slice(start,i);const code=json.substr(i+2,4);switch(code){case"0000":str+="\\0";break;case"0007":str+="\\a";break;case"000b":str+="\\v";break;case"001b":str+="\\e";break;case"0085":str+="\\N";break;case"00a0":str+="\\_";break;case"2028":str+="\\L";break;case"2029":str+="\\P";break;default:if(code.substr(0,2)==="00")str+="\\x"+code.substr(2);else str+=json.substr(i,6)}i+=5;start=i+1}break;case"n":if(implicitKey||json[i+2]==='"'||json.length\n";let chomp;let endStart;for(endStart=value.length;endStart>0;--endStart){const ch=value[endStart-1];if(ch!=="\n"&&ch!==" "&&ch!==" ")break}let end=value.substring(endStart);const endNlPos=end.indexOf("\n");if(endNlPos===-1){chomp="-"}else if(value===end||endNlPos!==end.length-1){chomp="+";if(onChompKeep)onChompKeep()}else{chomp=""}if(end){value=value.slice(0,-end.length);if(end[end.length-1]==="\n")end=end.slice(0,-1);end=end.replace(blockEndNewlines,`$&${indent}`)}let startWithSpace=false;let startEnd;let startNlPos=-1;for(startEnd=0;startEnd")+(startWithSpace?indentSize:"")+chomp;if(comment){header+=" "+commentString(comment.replace(/ ?[\r\n]+/g," "));if(onComment)onComment()}if(literal){value=value.replace(/\n+/g,`$&${indent}`);return`${header} ${indent}${start}${value}${end}`}value=value.replace(/\n+/g,"\n$&").replace(/(?:^|\n)([\t ].*)(?:([\n\t ]*)\n(?![\n\t ]))?/g,"$1$2").replace(/\n+/g,`$&${indent}`);const body=foldFlowLines(`${start}${value}${end}`,indent,FOLD_BLOCK,getFoldOptions(ctx,true));return`${header} ${indent}${body}`}function plainString(item,ctx,onComment,onChompKeep){const{type,value}=item;const{actualString,implicitKey,indent,indentStep,inFlow}=ctx;if(implicitKey&&value.includes("\n")||inFlow&&/[[\]{},]/.test(value)){return quotedString(value,ctx)}if(!value||/^[\n\t ,[\]{}#&*!|>'"%@`]|^[?-]$|^[?-][ \t]|[\n:][ \t]|[ \t]\n|[\n\t ]#|[\n\t :]$/.test(value)){return implicitKey||inFlow||!value.includes("\n")?quotedString(value,ctx):blockString(item,ctx,onComment,onChompKeep)}if(!implicitKey&&!inFlow&&type!==Scalar.PLAIN&&value.includes("\n")){return blockString(item,ctx,onComment,onChompKeep)}if(containsDocumentMarker(value)){if(indent===""){ctx.forceBlockIndent=true;return blockString(item,ctx,onComment,onChompKeep)}else if(implicitKey&&indent===indentStep){return quotedString(value,ctx)}}const str=value.replace(/\n+/g,`$& ${indent}`);if(actualString){const test=tag=>tag.default&&tag.tag!=="tag:yaml.org,2002:str"&&tag.test?.test(str);const{compat,tags}=ctx.doc.schema;if(tags.some(test)||compat?.some(test))return quotedString(value,ctx)}return implicitKey?str:foldFlowLines(str,indent,FOLD_FLOW,getFoldOptions(ctx,false))}function stringifyString(item,ctx,onComment,onChompKeep){const{implicitKey,inFlow}=ctx;const ss=typeof item.value==="string"?item:Object.assign({},item,{value:String(item.value)});let{type}=item;if(type!==Scalar.QUOTE_DOUBLE){if(/[\x00-\x08\x0b-\x1f\x7f-\x9f\u{D800}-\u{DFFF}]/u.test(ss.value))type=Scalar.QUOTE_DOUBLE}const _stringify=_type=>{switch(_type){case Scalar.BLOCK_FOLDED:case Scalar.BLOCK_LITERAL:return implicitKey||inFlow?quotedString(ss.value,ctx):blockString(ss,ctx,onComment,onChompKeep);case Scalar.QUOTE_DOUBLE:return doubleQuotedString(ss.value,ctx);case Scalar.QUOTE_SINGLE:return singleQuotedString(ss.value,ctx);case Scalar.PLAIN:return plainString(ss,ctx,onComment,onChompKeep);default:return null}};let res=_stringify(type);if(res===null){const{defaultKeyType,defaultStringType}=ctx.options;const t=implicitKey&&defaultKeyType||defaultStringType;res=_stringify(t);if(res===null)throw new Error(`Unsupported default string type ${t}`)}return res}function createStringifyContext(doc,options){const opt=Object.assign({blockQuote:true,commentString:stringifyComment,defaultKeyType:null,defaultStringType:"PLAIN",directives:null,doubleQuotedAsJSON:false,doubleQuotedMinMultiLineLength:40,falseStr:"false",flowCollectionPadding:true,indentSeq:true,lineWidth:80,minContentWidth:20,nullStr:"null",simpleKeys:false,singleQuote:null,trueStr:"true",verifyAliasOrder:true},doc.schema.toStringOptions,options);let inFlow;switch(opt.collectionStyle){case"block":inFlow=false;break;case"flow":inFlow=true;break;default:inFlow=null}return{anchors:new Set,doc,flowCollectionPadding:opt.flowCollectionPadding?" ":"",indent:"",indentStep:typeof opt.indent==="number"?" ".repeat(opt.indent):" ",inFlow,options:opt}}function getTagObject(tags,item){if(item.tag){const match=tags.filter(t=>t.tag===item.tag);if(match.length>0)return match.find(t=>t.format===item.format)??match[0]}let tagObj=void 0;let obj;if(isScalar(item)){obj=item.value;const match=tags.filter(t=>t.identify?.(obj));tagObj=match.find(t=>t.format===item.format)??match.find(t=>!t.format)}else{obj=item;tagObj=tags.find(t=>t.nodeClass&&obj instanceof t.nodeClass)}if(!tagObj){const name=obj?.constructor?.name??typeof obj;throw new Error(`Tag not resolved for ${name} value`)}return tagObj}function stringifyProps(node,tagObj,{anchors,doc}){if(!doc.directives)return"";const props=[];const anchor=(isScalar(node)||isCollection(node))&&node.anchor;if(anchor&&anchorIsValid(anchor)){anchors.add(anchor);props.push(`&${anchor}`)}const tag=node.tag?node.tag:tagObj.default?null:tagObj.tag;if(tag)props.push(doc.directives.tagString(tag));return props.join(" ")}function stringify(item,ctx,onComment,onChompKeep){if(isPair(item))return item.toString(ctx,onComment,onChompKeep);if(isAlias(item)){if(ctx.doc.directives)return item.toString(ctx);if(ctx.resolvedAliases?.has(item)){throw new TypeError(`Cannot stringify circular structure without alias nodes`)}else{if(ctx.resolvedAliases)ctx.resolvedAliases.add(item);else ctx.resolvedAliases=new Set([item]);item=item.resolve(ctx.doc)}}let tagObj=void 0;const node=isNode(item)?item:ctx.doc.createNode(item,{onTagObj:o=>tagObj=o});if(!tagObj)tagObj=getTagObject(ctx.doc.schema.tags,node);const props=stringifyProps(node,tagObj,ctx);if(props.length>0)ctx.indentAtStart=(ctx.indentAtStart??0)+props.length+1;const str=typeof tagObj.stringify==="function"?tagObj.stringify(node,ctx,onComment,onChompKeep):isScalar(node)?stringifyString(node,ctx,onComment,onChompKeep):node.toString(ctx,onComment,onChompKeep);if(!props)return str;return isScalar(node)||str[0]==="{"||str[0]==="["?`${props} ${str}`:`${props} ${ctx.indent}${str}`}function stringifyPair({key,value},ctx,onComment,onChompKeep){const{allNullValues,doc,indent,indentStep,options:{commentString,indentSeq,simpleKeys}}=ctx;let keyComment=isNode(key)&&key.comment||null;if(simpleKeys){if(keyComment){throw new Error("With simple keys, key nodes cannot have comments")}if(isCollection(key)){const msg="With simple keys, collection cannot be used as a key value";throw new Error(msg)}}let explicitKey=!simpleKeys&&(!key||keyComment&&value==null&&!ctx.inFlow||isCollection(key)||(isScalar(key)?key.type===Scalar.BLOCK_FOLDED||key.type===Scalar.BLOCK_LITERAL:typeof key==="object"));ctx=Object.assign({},ctx,{allNullValues:false,implicitKey:!explicitKey&&(simpleKeys||!allNullValues),indent:indent+indentStep});let keyCommentDone=false;let chompKeep=false;let str=stringify(key,ctx,()=>keyCommentDone=true,()=>chompKeep=true);if(!explicitKey&&!ctx.inFlow&&str.length>1024){if(simpleKeys)throw new Error("With simple keys, single line scalar must not span more than 1024 characters");explicitKey=true}if(ctx.inFlow){if(allNullValues||value==null){if(keyCommentDone&&onComment)onComment();return str===""?"?":explicitKey?`? ${str}`:str}}else if(allNullValues&&!simpleKeys||value==null&&explicitKey){str=`? ${str}`;if(keyComment&&!keyCommentDone){str+=lineComment(str,ctx.indent,commentString(keyComment))}else if(chompKeep&&onChompKeep)onChompKeep();return str}if(keyCommentDone)keyComment=null;if(explicitKey){if(keyComment)str+=lineComment(str,ctx.indent,commentString(keyComment));str=`? ${str} ${indent}:`}else{str=`${str}:`;if(keyComment)str+=lineComment(str,ctx.indent,commentString(keyComment))}let vsb,vcb,valueComment;if(isNode(value)){vsb=!!value.spaceBefore;vcb=value.commentBefore;valueComment=value.comment}else{vsb=false;vcb=null;valueComment=null;if(value&&typeof value==="object")value=doc.createNode(value)}ctx.implicitKey=false;if(!explicitKey&&!keyComment&&isScalar(value))ctx.indentAtStart=str.length+1;chompKeep=false;if(!indentSeq&&indentStep.length>=2&&!ctx.inFlow&&!explicitKey&&isSeq(value)&&!value.flow&&!value.tag&&!value.anchor){ctx.indent=ctx.indent.substring(2)}let valueCommentDone=false;const valueStr=stringify(value,ctx,()=>valueCommentDone=true,()=>chompKeep=true);let ws=" ";if(keyComment||vsb||vcb){ws=vsb?"\n":"";if(vcb){const cs=commentString(vcb);ws+=` ${indentComment(cs,ctx.indent)}`}if(valueStr===""&&!ctx.inFlow){if(ws==="\n")ws="\n\n"}else{ws+=` ${ctx.indent}`}}else if(!explicitKey&&isCollection(value)){const vs0=valueStr[0];const nl0=valueStr.indexOf("\n");const hasNewline=nl0!==-1;const flow=ctx.inFlow??value.flow??value.items.length===0;if(hasNewline||!flow){let hasPropsLine=false;if(hasNewline&&(vs0==="&"||vs0==="!")){let sp0=valueStr.indexOf(" ");if(vs0==="&"&&sp0!==-1&&sp0key===MERGE_KEY||isScalar(key)&&key.value===MERGE_KEY&&(!key.type||key.type===Scalar.PLAIN);function mergeToJSMap(ctx,map2,value){const source=ctx&&isAlias(value)?value.resolve(ctx.doc):value;if(!isMap(source))throw new Error("Merge sources must be maps or map aliases");const srcMap=source.toJSON(null,ctx,Map);for(const[key,value2]of srcMap){if(map2 instanceof Map){if(!map2.has(key))map2.set(key,value2)}else if(map2 instanceof Set){map2.add(key)}else if(!Object.prototype.hasOwnProperty.call(map2,key)){Object.defineProperty(map2,key,{value:value2,writable:true,enumerable:true,configurable:true})}}return map2}function stringifyKey(key,jsKey,ctx){if(jsKey===null)return"";if(typeof jsKey!=="object")return String(jsKey);if(isNode(key)&&ctx?.doc){const strCtx=createStringifyContext(ctx.doc,{});strCtx.anchors=new Set;for(const node of ctx.anchors.keys())strCtx.anchors.add(node.anchor);strCtx.inFlow=true;strCtx.inStringifyKey=true;const strKey=key.toString(strCtx);if(!ctx.mapKeyWarned){let jsonStr=JSON.stringify(strKey);if(jsonStr.length>40)jsonStr=jsonStr.substring(0,36)+'..."';warn(ctx.doc.options.logLevel,`Keys with collection values will be stringified due to JS Object restrictions: ${jsonStr}. Set mapAsMap: true to use object keys.`);ctx.mapKeyWarned=true}return strKey}return JSON.stringify(jsKey)}function createPair(key,value,ctx){const k=createNode(key,void 0,ctx);const v=createNode(value,void 0,ctx);return new Pair(k,v)}var Pair=class _Pair{constructor(key,value=null){Object.defineProperty(this,NODE_TYPE,{value:PAIR});this.key=key;this.value=value}clone(schema4){let{key,value}=this;if(isNode(key))key=key.clone(schema4);if(isNode(value))value=value.clone(schema4);return new _Pair(key,value)}toJSON(_,ctx){const pair=ctx?.mapAsMap?new Map:{};return addPairToJSMap(ctx,pair,this)}toString(ctx,onComment,onChompKeep){return ctx?.doc?stringifyPair(this,ctx,onComment,onChompKeep):JSON.stringify(this)}};function stringifyCollection(collection,ctx,options){const flow=ctx.inFlow??collection.flow;const stringify4=flow?stringifyFlowCollection:stringifyBlockCollection;return stringify4(collection,ctx,options)}function stringifyBlockCollection({comment,items},ctx,{blockItemPrefix,flowChars,itemIndent,onChompKeep,onComment}){const{indent,options:{commentString}}=ctx;const itemCtx=Object.assign({},ctx,{indent:itemIndent,type:null});let chompKeep=false;const lines=[];for(let i=0;icomment2=null,()=>chompKeep=true);if(comment2)str2+=lineComment(str2,itemIndent,commentString(comment2));if(chompKeep&&comment2)chompKeep=false;lines.push(blockItemPrefix+str2)}let str;if(lines.length===0){str=flowChars.start+flowChars.end}else{str=lines[0];for(let i=1;icomment2=null);if(ilinesAtValue||str2.includes("\n")))reqNewline=true;lines.push(str2);linesAtValue=lines.length}let str;const{start,end}=flowChars;if(lines.length===0){str=start+end}else{if(!reqNewline){const len=lines.reduce((sum,line)=>sum+line.length+2,2);reqNewline=ctx.options.lineWidth>0&&len>ctx.options.lineWidth}if(reqNewline){str=start;for(const line of lines)str+=line?` ${indentStep}${indent}${line}`:"\n";str+=` ${indent}${end}`}else{str=`${start}${fcPadding}${lines.join(" ")}${fcPadding}${end}`}}if(comment){str+=lineComment(str,indent,commentString(comment));if(onComment)onComment()}return str}function addCommentBefore({indent,options:{commentString}},lines,comment,chompKeep){if(comment&&chompKeep)comment=comment.replace(/^\n+/,"");if(comment){const ic=indentComment(commentString(comment),indent);lines.push(ic.trimStart())}}function findPair(items,key){const k=isScalar(key)?key.value:key;for(const it of items){if(isPair(it)){if(it.key===key||it.key===k)return it;if(isScalar(it.key)&&it.key.value===k)return it}}return void 0}var YAMLMap=class extends Collection{static get tagName(){return"tag:yaml.org,2002:map"}constructor(schema4){super(MAP,schema4);this.items=[]}static from(schema4,obj,ctx){const{keepUndefined,replacer}=ctx;const map2=new this(schema4);const add=(key,value)=>{if(typeof replacer==="function")value=replacer.call(obj,key,value);else if(Array.isArray(replacer)&&!replacer.includes(key))return;if(value!==void 0||keepUndefined)map2.items.push(createPair(key,value,ctx))};if(obj instanceof Map){for(const[key,value]of obj)add(key,value)}else if(obj&&typeof obj==="object"){for(const key of Object.keys(obj))add(key,obj[key])}if(typeof schema4.sortMapEntries==="function"){map2.items.sort(schema4.sortMapEntries)}return map2}add(pair,overwrite){let _pair;if(isPair(pair))_pair=pair;else if(!pair||typeof pair!=="object"||!("key"in pair)){_pair=new Pair(pair,pair?.value)}else _pair=new Pair(pair.key,pair.value);const prev=findPair(this.items,_pair.key);const sortEntries=this.schema?.sortMapEntries;if(prev){if(!overwrite)throw new Error(`Key ${_pair.key} already set`);if(isScalar(prev.value)&&isScalarValue(_pair.value))prev.value.value=_pair.value;else prev.value=_pair.value}else if(sortEntries){const i=this.items.findIndex(item=>sortEntries(_pair,item)<0);if(i===-1)this.items.push(_pair);else this.items.splice(i,0,_pair)}else{this.items.push(_pair)}}delete(key){const it=findPair(this.items,key);if(!it)return false;const del=this.items.splice(this.items.indexOf(it),1);return del.length>0}get(key,keepScalar){const it=findPair(this.items,key);const node=it?.value;return(!keepScalar&&isScalar(node)?node.value:node)??void 0}has(key){return!!findPair(this.items,key)}set(key,value){this.add(new Pair(key,value),true)}toJSON(_,ctx,Type){const map2=Type?new Type:ctx?.mapAsMap?new Map:{};if(ctx?.onCreate)ctx.onCreate(map2);for(const item of this.items)addPairToJSMap(ctx,map2,item);return map2}toString(ctx,onComment,onChompKeep){if(!ctx)return JSON.stringify(this);for(const item of this.items){if(!isPair(item))throw new Error(`Map items must all be pairs; found ${JSON.stringify(item)} instead`)}if(!ctx.allNullValues&&this.hasAllNullValues(false))ctx=Object.assign({},ctx,{allNullValues:true});return stringifyCollection(this,ctx,{blockItemPrefix:"",flowChars:{start:"{",end:"}"},itemIndent:ctx.indent||"",onChompKeep,onComment})}};var map={collection:"map",default:true,nodeClass:YAMLMap,tag:"tag:yaml.org,2002:map",resolve(map2,onError){if(!isMap(map2))onError("Expected a mapping for this tag");return map2},createNode:(schema4,obj,ctx)=>YAMLMap.from(schema4,obj,ctx)};var YAMLSeq=class extends Collection{static get tagName(){return"tag:yaml.org,2002:seq"}constructor(schema4){super(SEQ,schema4);this.items=[]}add(value){this.items.push(value)}delete(key){const idx=asItemIndex(key);if(typeof idx!=="number")return false;const del=this.items.splice(idx,1);return del.length>0}get(key,keepScalar){const idx=asItemIndex(key);if(typeof idx!=="number")return void 0;const it=this.items[idx];return!keepScalar&&isScalar(it)?it.value:it}has(key){const idx=asItemIndex(key);return typeof idx==="number"&&idx=0?idx:null}var seq={collection:"seq",default:true,nodeClass:YAMLSeq,tag:"tag:yaml.org,2002:seq",resolve(seq2,onError){if(!isSeq(seq2))onError("Expected a sequence for this tag");return seq2},createNode:(schema4,obj,ctx)=>YAMLSeq.from(schema4,obj,ctx)};var string={identify:value=>typeof value==="string",default:true,tag:"tag:yaml.org,2002:str",resolve:str=>str,stringify(item,ctx,onComment,onChompKeep){ctx=Object.assign({actualString:true},ctx);return stringifyString(item,ctx,onComment,onChompKeep)}};var nullTag={identify:value=>value==null,createNode:()=>new Scalar(null),default:true,tag:"tag:yaml.org,2002:null",test:/^(?:~|[Nn]ull|NULL)?$/,resolve:()=>new Scalar(null),stringify:({source},ctx)=>typeof source==="string"&&nullTag.test.test(source)?source:ctx.options.nullStr};var boolTag={identify:value=>typeof value==="boolean",default:true,tag:"tag:yaml.org,2002:bool",test:/^(?:[Tt]rue|TRUE|[Ff]alse|FALSE)$/,resolve:str=>new Scalar(str[0]==="t"||str[0]==="T"),stringify({source,value},ctx){if(source&&boolTag.test.test(source)){const sv=source[0]==="t"||source[0]==="T";if(value===sv)return source}return value?ctx.options.trueStr:ctx.options.falseStr}};function stringifyNumber({format,minFractionDigits,tag,value}){if(typeof value==="bigint")return String(value);const num=typeof value==="number"?value:Number(value);if(!isFinite(num))return isNaN(num)?".nan":num<0?"-.inf":".inf";let n=JSON.stringify(value);if(!format&&minFractionDigits&&(!tag||tag==="tag:yaml.org,2002:float")&&/^\d/.test(n)){let i=n.indexOf(".");if(i<0){i=n.length;n+="."}let d=minFractionDigits-(n.length-i-1);while(d-- >0)n+="0"}return n}var floatNaN={identify:value=>typeof value==="number",default:true,tag:"tag:yaml.org,2002:float",test:/^(?:[-+]?\.(?:inf|Inf|INF|nan|NaN|NAN))$/,resolve:str=>str.slice(-3).toLowerCase()==="nan"?NaN:str[0]==="-"?Number.NEGATIVE_INFINITY:Number.POSITIVE_INFINITY,stringify:stringifyNumber};var floatExp={identify:value=>typeof value==="number",default:true,tag:"tag:yaml.org,2002:float",format:"EXP",test:/^[-+]?(?:\.[0-9]+|[0-9]+(?:\.[0-9]*)?)[eE][-+]?[0-9]+$/,resolve:str=>parseFloat(str),stringify(node){const num=Number(node.value);return isFinite(num)?num.toExponential():stringifyNumber(node)}};var float={identify:value=>typeof value==="number",default:true,tag:"tag:yaml.org,2002:float",test:/^[-+]?(?:\.[0-9]+|[0-9]+\.[0-9]*)$/,resolve(str){const node=new Scalar(parseFloat(str));const dot=str.indexOf(".");if(dot!==-1&&str[str.length-1]==="0")node.minFractionDigits=str.length-dot-1;return node},stringify:stringifyNumber};var intIdentify=value=>typeof value==="bigint"||Number.isInteger(value);var intResolve=(str,offset,radix,{intAsBigInt})=>intAsBigInt?BigInt(str):parseInt(str.substring(offset),radix);function intStringify(node,radix,prefix){const{value}=node;if(intIdentify(value)&&value>=0)return prefix+value.toString(radix);return stringifyNumber(node)}var intOct={identify:value=>intIdentify(value)&&value>=0,default:true,tag:"tag:yaml.org,2002:int",format:"OCT",test:/^0o[0-7]+$/,resolve:(str,_onError,opt)=>intResolve(str,2,8,opt),stringify:node=>intStringify(node,8,"0o")};var int={identify:intIdentify,default:true,tag:"tag:yaml.org,2002:int",test:/^[-+]?[0-9]+$/,resolve:(str,_onError,opt)=>intResolve(str,0,10,opt),stringify:stringifyNumber};var intHex={identify:value=>intIdentify(value)&&value>=0,default:true,tag:"tag:yaml.org,2002:int",format:"HEX",test:/^0x[0-9a-fA-F]+$/,resolve:(str,_onError,opt)=>intResolve(str,2,16,opt),stringify:node=>intStringify(node,16,"0x")};var schema=[map,seq,string,nullTag,boolTag,intOct,int,intHex,floatNaN,floatExp,float];function intIdentify2(value){return typeof value==="bigint"||Number.isInteger(value)}var stringifyJSON=({value})=>JSON.stringify(value);var jsonScalars=[{identify:value=>typeof value==="string",default:true,tag:"tag:yaml.org,2002:str",resolve:str=>str,stringify:stringifyJSON},{identify:value=>value==null,createNode:()=>new Scalar(null),default:true,tag:"tag:yaml.org,2002:null",test:/^null$/,resolve:()=>null,stringify:stringifyJSON},{identify:value=>typeof value==="boolean",default:true,tag:"tag:yaml.org,2002:bool",test:/^true|false$/,resolve:str=>str==="true",stringify:stringifyJSON},{identify:intIdentify2,default:true,tag:"tag:yaml.org,2002:int",test:/^-?(?:0|[1-9][0-9]*)$/,resolve:(str,_onError,{intAsBigInt})=>intAsBigInt?BigInt(str):parseInt(str,10),stringify:({value})=>intIdentify2(value)?value.toString():JSON.stringify(value)},{identify:value=>typeof value==="number",default:true,tag:"tag:yaml.org,2002:float",test:/^-?(?:0|[1-9][0-9]*)(?:\.[0-9]*)?(?:[eE][-+]?[0-9]+)?$/,resolve:str=>parseFloat(str),stringify:stringifyJSON}];var jsonError={default:true,tag:"",test:/^/,resolve(str,onError){onError(`Unresolved plain scalar ${JSON.stringify(str)}`);return str}};var schema2=[map,seq].concat(jsonScalars,jsonError);var binary={identify:value=>value instanceof Uint8Array,default:false,tag:"tag:yaml.org,2002:binary",resolve(src,onError){if(typeof Buffer==="function"){return Buffer.from(src,"base64")}else if(typeof atob==="function"){const str=atob(src.replace(/[\n\r]/g,""));const buffer=new Uint8Array(str.length);for(let i=0;i1)onError("Each pair must have its own sequence indicator");const pair=item.items[0]||new Pair(new Scalar(null));if(item.commentBefore)pair.key.commentBefore=pair.key.commentBefore?`${item.commentBefore} ${pair.key.commentBefore}`:item.commentBefore;if(item.comment){const cn=pair.value??pair.key;cn.comment=cn.comment?`${item.comment} ${cn.comment}`:item.comment}item=pair}seq2.items[i]=isPair(item)?item:new Pair(item)}}else onError("Expected a sequence for this tag");return seq2}function createPairs(schema4,iterable,ctx){const{replacer}=ctx;const pairs2=new YAMLSeq(schema4);pairs2.tag="tag:yaml.org,2002:pairs";let i=0;if(iterable&&Symbol.iterator in Object(iterable))for(let it of iterable){if(typeof replacer==="function")it=replacer.call(iterable,String(i++),it);let key,value;if(Array.isArray(it)){if(it.length===2){key=it[0];value=it[1]}else throw new TypeError(`Expected [key, value] tuple: ${it}`)}else if(it&&it instanceof Object){const keys=Object.keys(it);if(keys.length===1){key=keys[0];value=it[key]}else{throw new TypeError(`Expected tuple with one key, not ${keys.length} keys`)}}else{key=it}pairs2.items.push(createPair(key,value,ctx))}return pairs2}var pairs={collection:"seq",default:false,tag:"tag:yaml.org,2002:pairs",resolve:resolvePairs,createNode:createPairs};var YAMLOMap=class _YAMLOMap extends YAMLSeq{constructor(){super();this.add=YAMLMap.prototype.add.bind(this);this.delete=YAMLMap.prototype.delete.bind(this);this.get=YAMLMap.prototype.get.bind(this);this.has=YAMLMap.prototype.has.bind(this);this.set=YAMLMap.prototype.set.bind(this);this.tag=_YAMLOMap.tag}toJSON(_,ctx){if(!ctx)return super.toJSON(_);const map2=new Map;if(ctx?.onCreate)ctx.onCreate(map2);for(const pair of this.items){let key,value;if(isPair(pair)){key=toJS(pair.key,"",ctx);value=toJS(pair.value,key,ctx)}else{key=toJS(pair,"",ctx)}if(map2.has(key))throw new Error("Ordered maps must not include duplicate keys");map2.set(key,value)}return map2}static from(schema4,iterable,ctx){const pairs2=createPairs(schema4,iterable,ctx);const omap2=new this;omap2.items=pairs2.items;return omap2}};YAMLOMap.tag="tag:yaml.org,2002:omap";var omap={collection:"seq",identify:value=>value instanceof Map,nodeClass:YAMLOMap,default:false,tag:"tag:yaml.org,2002:omap",resolve(seq2,onError){const pairs2=resolvePairs(seq2,onError);const seenKeys=[];for(const{key}of pairs2.items){if(isScalar(key)){if(seenKeys.includes(key.value)){onError(`Ordered maps must not include duplicate keys: ${key.value}`)}else{seenKeys.push(key.value)}}}return Object.assign(new YAMLOMap,pairs2)},createNode:(schema4,iterable,ctx)=>YAMLOMap.from(schema4,iterable,ctx)};function boolStringify({value,source},ctx){const boolObj=value?trueTag:falseTag;if(source&&boolObj.test.test(source))return source;return value?ctx.options.trueStr:ctx.options.falseStr}var trueTag={identify:value=>value===true,default:true,tag:"tag:yaml.org,2002:bool",test:/^(?:Y|y|[Yy]es|YES|[Tt]rue|TRUE|[Oo]n|ON)$/,resolve:()=>new Scalar(true),stringify:boolStringify};var falseTag={identify:value=>value===false,default:true,tag:"tag:yaml.org,2002:bool",test:/^(?:N|n|[Nn]o|NO|[Ff]alse|FALSE|[Oo]ff|OFF)$/i,resolve:()=>new Scalar(false),stringify:boolStringify};var floatNaN2={identify:value=>typeof value==="number",default:true,tag:"tag:yaml.org,2002:float",test:/^[-+]?\.(?:inf|Inf|INF|nan|NaN|NAN)$/,resolve:str=>str.slice(-3).toLowerCase()==="nan"?NaN:str[0]==="-"?Number.NEGATIVE_INFINITY:Number.POSITIVE_INFINITY,stringify:stringifyNumber};var floatExp2={identify:value=>typeof value==="number",default:true,tag:"tag:yaml.org,2002:float",format:"EXP",test:/^[-+]?(?:[0-9][0-9_]*)?(?:\.[0-9_]*)?[eE][-+]?[0-9]+$/,resolve:str=>parseFloat(str.replace(/_/g,"")),stringify(node){const num=Number(node.value);return isFinite(num)?num.toExponential():stringifyNumber(node)}};var float2={identify:value=>typeof value==="number",default:true,tag:"tag:yaml.org,2002:float",test:/^[-+]?(?:[0-9][0-9_]*)?\.[0-9_]*$/,resolve(str){const node=new Scalar(parseFloat(str.replace(/_/g,"")));const dot=str.indexOf(".");if(dot!==-1){const f=str.substring(dot+1).replace(/_/g,"");if(f[f.length-1]==="0")node.minFractionDigits=f.length}return node},stringify:stringifyNumber};var intIdentify3=value=>typeof value==="bigint"||Number.isInteger(value);function intResolve2(str,offset,radix,{intAsBigInt}){const sign=str[0];if(sign==="-"||sign==="+")offset+=1;str=str.substring(offset).replace(/_/g,"");if(intAsBigInt){switch(radix){case 2:str=`0b${str}`;break;case 8:str=`0o${str}`;break;case 16:str=`0x${str}`;break}const n2=BigInt(str);return sign==="-"?BigInt(-1)*n2:n2}const n=parseInt(str,radix);return sign==="-"?-1*n:n}function intStringify2(node,radix,prefix){const{value}=node;if(intIdentify3(value)){const str=value.toString(radix);return value<0?"-"+prefix+str.substr(1):prefix+str}return stringifyNumber(node)}var intBin={identify:intIdentify3,default:true,tag:"tag:yaml.org,2002:int",format:"BIN",test:/^[-+]?0b[0-1_]+$/,resolve:(str,_onError,opt)=>intResolve2(str,2,2,opt),stringify:node=>intStringify2(node,2,"0b")};var intOct2={identify:intIdentify3,default:true,tag:"tag:yaml.org,2002:int",format:"OCT",test:/^[-+]?0[0-7_]+$/,resolve:(str,_onError,opt)=>intResolve2(str,1,8,opt),stringify:node=>intStringify2(node,8,"0")};var int2={identify:intIdentify3,default:true,tag:"tag:yaml.org,2002:int",test:/^[-+]?[0-9][0-9_]*$/,resolve:(str,_onError,opt)=>intResolve2(str,0,10,opt),stringify:stringifyNumber};var intHex2={identify:intIdentify3,default:true,tag:"tag:yaml.org,2002:int",format:"HEX",test:/^[-+]?0x[0-9a-fA-F_]+$/,resolve:(str,_onError,opt)=>intResolve2(str,2,16,opt),stringify:node=>intStringify2(node,16,"0x")};var YAMLSet=class _YAMLSet extends YAMLMap{constructor(schema4){super(schema4);this.tag=_YAMLSet.tag}add(key){let pair;if(isPair(key))pair=key;else if(key&&typeof key==="object"&&"key"in key&&"value"in key&&key.value===null)pair=new Pair(key.key,null);else pair=new Pair(key,null);const prev=findPair(this.items,pair.key);if(!prev)this.items.push(pair)}get(key,keepPair){const pair=findPair(this.items,key);return!keepPair&&isPair(pair)?isScalar(pair.key)?pair.key.value:pair.key:pair}set(key,value){if(typeof value!=="boolean")throw new Error(`Expected boolean value for set(key, value) in a YAML set, not ${typeof value}`);const prev=findPair(this.items,key);if(prev&&!value){this.items.splice(this.items.indexOf(prev),1)}else if(!prev&&value){this.items.push(new Pair(key))}}toJSON(_,ctx){return super.toJSON(_,ctx,Set)}toString(ctx,onComment,onChompKeep){if(!ctx)return JSON.stringify(this);if(this.hasAllNullValues(true))return super.toString(Object.assign({},ctx,{allNullValues:true}),onComment,onChompKeep);else throw new Error("Set items must all have null values")}static from(schema4,iterable,ctx){const{replacer}=ctx;const set2=new this(schema4);if(iterable&&Symbol.iterator in Object(iterable))for(let value of iterable){if(typeof replacer==="function")value=replacer.call(iterable,value,value);set2.items.push(createPair(value,null,ctx))}return set2}};YAMLSet.tag="tag:yaml.org,2002:set";var set={collection:"map",identify:value=>value instanceof Set,nodeClass:YAMLSet,default:false,tag:"tag:yaml.org,2002:set",createNode:(schema4,iterable,ctx)=>YAMLSet.from(schema4,iterable,ctx),resolve(map2,onError){if(isMap(map2)){if(map2.hasAllNullValues(true))return Object.assign(new YAMLSet,map2);else onError("Set items must all have null values")}else onError("Expected a mapping for this tag");return map2}};function parseSexagesimal(str,asBigInt){const sign=str[0];const parts=sign==="-"||sign==="+"?str.substring(1):str;const num=n=>asBigInt?BigInt(n):Number(n);const res=parts.replace(/_/g,"").split(":").reduce((res2,p)=>res2*num(60)+num(p),num(0));return sign==="-"?num(-1)*res:res}function stringifySexagesimal(node){let{value}=node;let num=n=>n;if(typeof value==="bigint")num=n=>BigInt(n);else if(isNaN(value)||!isFinite(value))return stringifyNumber(node);let sign="";if(value<0){sign="-";value*=num(-1)}const _60=num(60);const parts=[value%_60];if(value<60){parts.unshift(0)}else{value=(value-parts[0])/_60;parts.unshift(value%_60);if(value>=60){value=(value-parts[0])/_60;parts.unshift(value)}}return sign+parts.map(n=>String(n).padStart(2,"0")).join(":").replace(/000000\d*$/,"")}var intTime={identify:value=>typeof value==="bigint"||Number.isInteger(value),default:true,tag:"tag:yaml.org,2002:int",format:"TIME",test:/^[-+]?[0-9][0-9_]*(?::[0-5]?[0-9])+$/,resolve:(str,_onError,{intAsBigInt})=>parseSexagesimal(str,intAsBigInt),stringify:stringifySexagesimal};var floatTime={identify:value=>typeof value==="number",default:true,tag:"tag:yaml.org,2002:float",format:"TIME",test:/^[-+]?[0-9][0-9_]*(?::[0-5]?[0-9])+\.[0-9_]*$/,resolve:str=>parseSexagesimal(str,false),stringify:stringifySexagesimal};var timestamp={identify:value=>value instanceof Date,default:true,tag:"tag:yaml.org,2002:timestamp",test:RegExp("^([0-9]{4})-([0-9]{1,2})-([0-9]{1,2})(?:(?:t|T|[ \\t]+)([0-9]{1,2}):([0-9]{1,2}):([0-9]{1,2}(\\.[0-9]+)?)(?:[ \\t]*(Z|[-+][012]?[0-9](?::[0-9]{2})?))?)?$"),resolve(str){const match=str.match(timestamp.test);if(!match)throw new Error("!!timestamp expects a date, starting with yyyy-mm-dd");const[,year,month,day,hour,minute,second]=match.map(Number);const millisec=match[7]?Number((match[7]+"00").substr(1,3)):0;let date=Date.UTC(year,month-1,day,hour||0,minute||0,second||0,millisec);const tz=match[8];if(tz&&tz!=="Z"){let d=parseSexagesimal(tz,false);if(Math.abs(d)<30)d*=60;date-=6e4*d}return new Date(date)},stringify:({value})=>value.toISOString().replace(/((T00:00)?:00)?\.000Z$/,"")};var schema3=[map,seq,string,nullTag,trueTag,falseTag,intBin,intOct2,int2,intHex2,floatNaN2,floatExp2,float2,binary,omap,pairs,set,intTime,floatTime,timestamp];var schemas=new Map([["core",schema],["failsafe",[map,seq,string]],["json",schema2],["yaml11",schema3],["yaml-1.1",schema3]]);var tagsByName={binary,bool:boolTag,float,floatExp,floatNaN,floatTime,int,intHex,intOct,intTime,map,null:nullTag,omap,pairs,seq,set,timestamp};var coreKnownTags={"tag:yaml.org,2002:binary":binary,"tag:yaml.org,2002:omap":omap,"tag:yaml.org,2002:pairs":pairs,"tag:yaml.org,2002:set":set,"tag:yaml.org,2002:timestamp":timestamp};function getTags(customTags,schemaName){let tags=schemas.get(schemaName);if(!tags){if(Array.isArray(customTags))tags=[];else{const keys=Array.from(schemas.keys()).filter(key=>key!=="yaml11").map(key=>JSON.stringify(key)).join(", ");throw new Error(`Unknown schema "${schemaName}"; use one of ${keys} or define customTags array`)}}if(Array.isArray(customTags)){for(const tag of customTags)tags=tags.concat(tag)}else if(typeof customTags==="function"){tags=customTags(tags.slice())}return tags.map(tag=>{if(typeof tag!=="string")return tag;const tagObj=tagsByName[tag];if(tagObj)return tagObj;const keys=Object.keys(tagsByName).map(key=>JSON.stringify(key)).join(", ");throw new Error(`Unknown custom tag "${tag}"; use one of ${keys}`)})}var sortMapEntriesByKey=(a,b)=>a.keyb.key?1:0;var Schema=class _Schema{constructor({compat,customTags,merge,resolveKnownTags,schema:schema4,sortMapEntries,toStringDefaults}){this.compat=Array.isArray(compat)?getTags(compat,"compat"):compat?getTags(null,compat):null;this.merge=!!merge;this.name=typeof schema4==="string"&&schema4||"core";this.knownTags=resolveKnownTags?coreKnownTags:{};this.tags=getTags(customTags,this.name);this.toStringOptions=toStringDefaults??null;Object.defineProperty(this,MAP,{value:map});Object.defineProperty(this,SCALAR,{value:string});Object.defineProperty(this,SEQ,{value:seq});this.sortMapEntries=typeof sortMapEntries==="function"?sortMapEntries:sortMapEntries===true?sortMapEntriesByKey:null}clone(){const copy=Object.create(_Schema.prototype,Object.getOwnPropertyDescriptors(this));copy.tags=this.tags.slice();return copy}};function stringifyDocument(doc,options){const lines=[];let hasDirectives=options.directives===true;if(options.directives!==false&&doc.directives){const dir=doc.directives.toString(doc);if(dir){lines.push(dir);hasDirectives=true}else if(doc.directives.docStart)hasDirectives=true}if(hasDirectives)lines.push("---");const ctx=createStringifyContext(doc,options);const{commentString}=ctx.options;if(doc.commentBefore){if(lines.length!==1)lines.unshift("");const cs=commentString(doc.commentBefore);lines.unshift(indentComment(cs,""))}let chompKeep=false;let contentComment=null;if(doc.contents){if(isNode(doc.contents)){if(doc.contents.spaceBefore&&hasDirectives)lines.push("");if(doc.contents.commentBefore){const cs=commentString(doc.contents.commentBefore);lines.push(indentComment(cs,""))}ctx.forceBlockIndent=!!doc.comment;contentComment=doc.contents.comment}const onChompKeep=contentComment?void 0:()=>chompKeep=true;let body=stringify(doc.contents,ctx,()=>contentComment=null,onChompKeep);if(contentComment)body+=lineComment(body,"",commentString(contentComment));if((body[0]==="|"||body[0]===">")&&lines[lines.length-1]==="---"){lines[lines.length-1]=`--- ${body}`}else lines.push(body)}else{lines.push(stringify(doc.contents,ctx))}if(doc.directives?.docEnd){if(doc.comment){const cs=commentString(doc.comment);if(cs.includes("\n")){lines.push("...");lines.push(indentComment(cs,""))}else{lines.push(`... ${cs}`)}}else{lines.push("...")}}else{let dc=doc.comment;if(dc&&chompKeep)dc=dc.replace(/^\n+/,"");if(dc){if((!chompKeep||contentComment)&&lines[lines.length-1]!=="")lines.push("");lines.push(indentComment(commentString(dc),""))}}return lines.join("\n")+"\n"}var Document=class _Document{constructor(value,replacer,options){this.commentBefore=null;this.comment=null;this.errors=[];this.warnings=[];Object.defineProperty(this,NODE_TYPE,{value:DOC});let _replacer=null;if(typeof replacer==="function"||Array.isArray(replacer)){_replacer=replacer}else if(options===void 0&&replacer){options=replacer;replacer=void 0}const opt=Object.assign({intAsBigInt:false,keepSourceTokens:false,logLevel:"warn",prettyErrors:true,strict:true,uniqueKeys:true,version:"1.2"},options);this.options=opt;let{version}=opt;if(options?._directives){this.directives=options._directives.atDocument();if(this.directives.yaml.explicit)version=this.directives.yaml.version}else this.directives=new Directives({version});this.setSchema(version,options);this.contents=value===void 0?null:this.createNode(value,_replacer,options)}clone(){const copy=Object.create(_Document.prototype,{[NODE_TYPE]:{value:DOC}});copy.commentBefore=this.commentBefore;copy.comment=this.comment;copy.errors=this.errors.slice();copy.warnings=this.warnings.slice();copy.options=Object.assign({},this.options);if(this.directives)copy.directives=this.directives.clone();copy.schema=this.schema.clone();copy.contents=isNode(this.contents)?this.contents.clone(copy.schema):this.contents;if(this.range)copy.range=this.range.slice();return copy}add(value){if(assertCollection(this.contents))this.contents.add(value)}addIn(path,value){if(assertCollection(this.contents))this.contents.addIn(path,value)}createAlias(node,name){if(!node.anchor){const prev=anchorNames(this);node.anchor=!name||prev.has(name)?findNewAnchor(name||"a",prev):name}return new Alias(node.anchor)}createNode(value,replacer,options){let _replacer=void 0;if(typeof replacer==="function"){value=replacer.call({"":value},"",value);_replacer=replacer}else if(Array.isArray(replacer)){const keyToStr=v=>typeof v==="number"||v instanceof String||v instanceof Number;const asStr=replacer.filter(keyToStr).map(String);if(asStr.length>0)replacer=replacer.concat(asStr);_replacer=replacer}else if(options===void 0&&replacer){options=replacer;replacer=void 0}const{aliasDuplicateObjects,anchorPrefix,flow,keepUndefined,onTagObj,tag}=options??{};const{onAnchor,setAnchors,sourceObjects}=createNodeAnchors(this,anchorPrefix||"a");const ctx={aliasDuplicateObjects:aliasDuplicateObjects??true,keepUndefined:keepUndefined??false,onAnchor,onTagObj,replacer:_replacer,schema:this.schema,sourceObjects};const node=createNode(value,tag,ctx);if(flow&&isCollection(node))node.flow=true;setAnchors();return node}createPair(key,value,options={}){const k=this.createNode(key,null,options);const v=this.createNode(value,null,options);return new Pair(k,v)}delete(key){return assertCollection(this.contents)?this.contents.delete(key):false}deleteIn(path){if(isEmptyPath(path)){if(this.contents==null)return false;this.contents=null;return true}return assertCollection(this.contents)?this.contents.deleteIn(path):false}get(key,keepScalar){return isCollection(this.contents)?this.contents.get(key,keepScalar):void 0}getIn(path,keepScalar){if(isEmptyPath(path))return!keepScalar&&isScalar(this.contents)?this.contents.value:this.contents;return isCollection(this.contents)?this.contents.getIn(path,keepScalar):void 0}has(key){return isCollection(this.contents)?this.contents.has(key):false}hasIn(path){if(isEmptyPath(path))return this.contents!==void 0;return isCollection(this.contents)?this.contents.hasIn(path):false}set(key,value){if(this.contents==null){this.contents=collectionFromPath(this.schema,[key],value)}else if(assertCollection(this.contents)){this.contents.set(key,value)}}setIn(path,value){if(isEmptyPath(path)){this.contents=value}else if(this.contents==null){this.contents=collectionFromPath(this.schema,Array.from(path),value)}else if(assertCollection(this.contents)){this.contents.setIn(path,value)}}setSchema(version,options={}){if(typeof version==="number")version=String(version);let opt;switch(version){case"1.1":if(this.directives)this.directives.yaml.version="1.1";else this.directives=new Directives({version:"1.1"});opt={merge:true,resolveKnownTags:false,schema:"yaml-1.1"};break;case"1.2":case"next":if(this.directives)this.directives.yaml.version=version;else this.directives=new Directives({version});opt={merge:false,resolveKnownTags:true,schema:"core"};break;case null:if(this.directives)delete this.directives;opt=null;break;default:{const sv=JSON.stringify(version);throw new Error(`Expected '1.1', '1.2' or null as first argument, but found: ${sv}`)}}if(options.schema instanceof Object)this.schema=options.schema;else if(opt)this.schema=new Schema(Object.assign(opt,options));else throw new Error(`With a null YAML version, the { schema: Schema } option is required`)}toJS({json,jsonArg,mapAsMap,maxAliasCount,onAnchor,reviver}={}){const ctx={anchors:new Map,doc:this,keep:!json,mapAsMap:mapAsMap===true,mapKeyWarned:false,maxAliasCount:typeof maxAliasCount==="number"?maxAliasCount:100};const res=toJS(this.contents,jsonArg??"",ctx);if(typeof onAnchor==="function")for(const{count,res:res2}of ctx.anchors.values())onAnchor(res2,count);return typeof reviver==="function"?applyReviver(reviver,{"":res},"",res):res}toJSON(jsonArg,onAnchor){return this.toJS({json:true,jsonArg,mapAsMap:false,onAnchor})}toString(options={}){if(this.errors.length>0)throw new Error("Document with errors cannot be stringified");if("indent"in options&&(!Number.isInteger(options.indent)||Number(options.indent)<=0)){const s=JSON.stringify(options.indent);throw new Error(`"indent" option must be a positive integer, not ${s}`)}return stringifyDocument(this,options)}};function assertCollection(contents){if(isCollection(contents))return true;throw new Error("Expected a YAML collection as document contents")}var YAMLError=class extends Error{constructor(name,pos,code,message){super();this.name=name;this.code=code;this.message=message;this.pos=pos}};var YAMLParseError=class extends YAMLError{constructor(pos,code,message){super("YAMLParseError",pos,code,message)}};var YAMLWarning=class extends YAMLError{constructor(pos,code,message){super("YAMLWarning",pos,code,message)}};var prettifyError=(src,lc)=>error=>{if(error.pos[0]===-1)return;error.linePos=error.pos.map(pos=>lc.linePos(pos));const{line,col}=error.linePos[0];error.message+=` at line ${line}, column ${col}`;let ci=col-1;let lineStr=src.substring(lc.lineStarts[line-1],lc.lineStarts[line]).replace(/[\n\r]+$/,"");if(ci>=60&&lineStr.length>80){const trimStart=Math.min(ci-39,lineStr.length-79);lineStr="\u2026"+lineStr.substring(trimStart);ci-=trimStart-1}if(lineStr.length>80)lineStr=lineStr.substring(0,79)+"\u2026";if(line>1&&/^ *$/.test(lineStr.substring(0,ci))){let prev=src.substring(lc.lineStarts[line-2],lc.lineStarts[line-1]);if(prev.length>80)prev=prev.substring(0,79)+"\u2026\n";lineStr=prev+lineStr}if(/[^ ]/.test(lineStr)){let count=1;const end=error.linePos[1];if(end&&end.line===line&&end.col>col){count=Math.max(1,Math.min(end.col-col,80-ci))}const pointer=" ".repeat(ci)+"^".repeat(count);error.message+=`: ${lineStr} ${pointer} `}};function resolveProps(tokens,{flow,indicator,next,offset,onError,startOnNewline}){let spaceBefore=false;let atNewline=startOnNewline;let hasSpace=startOnNewline;let comment="";let commentSep="";let hasNewline=false;let hasNewlineAfterProp=false;let reqSpace=false;let anchor=null;let tag=null;let comma=null;let found=null;let start=null;for(const token of tokens){if(reqSpace){if(token.type!=="space"&&token.type!=="newline"&&token.type!=="comma")onError(token.offset,"MISSING_CHAR","Tags and anchors must be separated from the next token by white space");reqSpace=false}switch(token.type){case"space":if(!flow&&atNewline&&indicator!=="doc-start"&&token.source[0]===" ")onError(token,"TAB_AS_INDENT","Tabs are not allowed as indentation");hasSpace=true;break;case"comment":{if(!hasSpace)onError(token,"MISSING_CHAR","Comments must be separated from other tokens by white space characters");const cb=token.source.substring(1)||" ";if(!comment)comment=cb;else comment+=commentSep+cb;commentSep="";atNewline=false;break}case"newline":if(atNewline){if(comment)comment+=token.source;else spaceBefore=true}else commentSep+=token.source;atNewline=true;hasNewline=true;if(anchor||tag)hasNewlineAfterProp=true;hasSpace=true;break;case"anchor":if(anchor)onError(token,"MULTIPLE_ANCHORS","A node can have at most one anchor");if(token.source.endsWith(":"))onError(token.offset+token.source.length-1,"BAD_ALIAS","Anchor ending in : is ambiguous",true);anchor=token;if(start===null)start=token.offset;atNewline=false;hasSpace=false;reqSpace=true;break;case"tag":{if(tag)onError(token,"MULTIPLE_TAGS","A node can have at most one tag");tag=token;if(start===null)start=token.offset;atNewline=false;hasSpace=false;reqSpace=true;break}case indicator:if(anchor||tag)onError(token,"BAD_PROP_ORDER",`Anchors and tags must be after the ${token.source} indicator`);if(found)onError(token,"UNEXPECTED_TOKEN",`Unexpected ${token.source} in ${flow??"collection"}`);found=token;atNewline=false;hasSpace=false;break;case"comma":if(flow){if(comma)onError(token,"UNEXPECTED_TOKEN",`Unexpected , in ${flow}`);comma=token;atNewline=false;hasSpace=false;break}default:onError(token,"UNEXPECTED_TOKEN",`Unexpected ${token.type} token`);atNewline=false;hasSpace=false}}const last=tokens[tokens.length-1];const end=last?last.offset+last.source.length:offset;if(reqSpace&&next&&next.type!=="space"&&next.type!=="newline"&&next.type!=="comma"&&(next.type!=="scalar"||next.source!==""))onError(next.offset,"MISSING_CHAR","Tags and anchors must be separated from the next token by white space");return{comma,found,spaceBefore,comment,hasNewline,hasNewlineAfterProp,anchor,tag,end,start:start??end}}function containsNewline(key){if(!key)return null;switch(key.type){case"alias":case"scalar":case"double-quoted-scalar":case"single-quoted-scalar":if(key.source.includes("\n"))return true;if(key.end){for(const st of key.end)if(st.type==="newline")return true}return false;case"flow-collection":for(const it of key.items){for(const st of it.start)if(st.type==="newline")return true;if(it.sep){for(const st of it.sep)if(st.type==="newline")return true}if(containsNewline(it.key)||containsNewline(it.value))return true}return false;default:return true}}function flowIndentCheck(indent,fc,onError){if(fc?.type==="flow-collection"){const end=fc.end[0];if(end.indent===indent&&(end.source==="]"||end.source==="}")&&containsNewline(fc)){const msg="Flow end indicator should be more indented than parent";onError(end,"BAD_INDENT",msg,true)}}}function mapIncludes(ctx,items,search){const{uniqueKeys}=ctx.options;if(uniqueKeys===false)return false;const isEqual=typeof uniqueKeys==="function"?uniqueKeys:(a,b)=>a===b||isScalar(a)&&isScalar(b)&&a.value===b.value&&!(a.value==="<<"&&ctx.schema.merge);return items.some(pair=>isEqual(pair.key,search))}var startColMsg="All mapping items must start at the same column";function resolveBlockMap({composeNode:composeNode2,composeEmptyNode:composeEmptyNode2},ctx,bm,onError,tag){const NodeClass=tag?.nodeClass??YAMLMap;const map2=new NodeClass(ctx.schema);if(ctx.atRoot)ctx.atRoot=false;let offset=bm.offset;let commentEnd=null;for(const collItem of bm.items){const{start,key,sep,value}=collItem;const keyProps=resolveProps(start,{indicator:"explicit-key-ind",next:key??sep?.[0],offset,onError,startOnNewline:true});const implicitKey=!keyProps.found;if(implicitKey){if(key){if(key.type==="block-seq")onError(offset,"BLOCK_AS_IMPLICIT_KEY","A block sequence may not be used as an implicit map key");else if("indent"in key&&key.indent!==bm.indent)onError(offset,"BAD_INDENT",startColMsg)}if(!keyProps.anchor&&!keyProps.tag&&!sep){commentEnd=keyProps.end;if(keyProps.comment){if(map2.comment)map2.comment+="\n"+keyProps.comment;else map2.comment=keyProps.comment}continue}if(keyProps.hasNewlineAfterProp||containsNewline(key)){onError(key??start[start.length-1],"MULTILINE_IMPLICIT_KEY","Implicit keys need to be on a single line")}}else if(keyProps.found?.indent!==bm.indent){onError(offset,"BAD_INDENT",startColMsg)}const keyStart=keyProps.end;const keyNode=key?composeNode2(ctx,key,keyProps,onError):composeEmptyNode2(ctx,keyStart,start,null,keyProps,onError);if(ctx.schema.compat)flowIndentCheck(bm.indent,key,onError);if(mapIncludes(ctx,map2.items,keyNode))onError(keyStart,"DUPLICATE_KEY","Map keys must be unique");const valueProps=resolveProps(sep??[],{indicator:"map-value-ind",next:value,offset:keyNode.range[2],onError,startOnNewline:!key||key.type==="block-scalar"});offset=valueProps.end;if(valueProps.found){if(implicitKey){if(value?.type==="block-map"&&!valueProps.hasNewline)onError(offset,"BLOCK_AS_IMPLICIT_KEY","Nested mappings are not allowed in compact mappings");if(ctx.options.strict&&keyProps.starttoken&&(token.type==="block-map"||token.type==="block-seq");function resolveFlowCollection({composeNode:composeNode2,composeEmptyNode:composeEmptyNode2},ctx,fc,onError,tag){const isMap2=fc.start.source==="{";const fcName=isMap2?"flow map":"flow sequence";const NodeClass=tag?.nodeClass??(isMap2?YAMLMap:YAMLSeq);const coll=new NodeClass(ctx.schema);coll.flow=true;const atRoot=ctx.atRoot;if(atRoot)ctx.atRoot=false;let offset=fc.offset+fc.start.source.length;for(let i=0;i0){const end=resolveEnd(ee,cePos,ctx.options.strict,onError);if(end.comment){if(coll.comment)coll.comment+="\n"+end.comment;else coll.comment=end.comment}coll.range=[fc.offset,cePos,end.offset]}else{coll.range=[fc.offset,cePos,cePos]}return coll}function resolveCollection(CN2,ctx,token,onError,tagName,tag){const coll=token.type==="block-map"?resolveBlockMap(CN2,ctx,token,onError,tag):token.type==="block-seq"?resolveBlockSeq(CN2,ctx,token,onError,tag):resolveFlowCollection(CN2,ctx,token,onError,tag);const Coll=coll.constructor;if(tagName==="!"||tagName===Coll.tagName){coll.tag=Coll.tagName;return coll}if(tagName)coll.tag=tagName;return coll}function composeCollection(CN2,ctx,token,tagToken,onError){const tagName=!tagToken?null:ctx.directives.tagName(tagToken.source,msg=>onError(tagToken,"TAG_RESOLVE_FAILED",msg));const expType=token.type==="block-map"?"map":token.type==="block-seq"?"seq":token.start.source==="{"?"map":"seq";if(!tagToken||!tagName||tagName==="!"||tagName===YAMLMap.tagName&&expType==="map"||tagName===YAMLSeq.tagName&&expType==="seq"||!expType){return resolveCollection(CN2,ctx,token,onError,tagName)}let tag=ctx.schema.tags.find(t=>t.tag===tagName&&t.collection===expType);if(!tag){const kt=ctx.schema.knownTags[tagName];if(kt&&kt.collection===expType){ctx.schema.tags.push(Object.assign({},kt,{default:false}));tag=kt}else{if(kt?.collection){onError(tagToken,"BAD_COLLECTION_TYPE",`${kt.tag} used for ${expType} collection, but expects ${kt.collection}`,true)}else{onError(tagToken,"TAG_RESOLVE_FAILED",`Unresolved tag: ${tagName}`,true)}return resolveCollection(CN2,ctx,token,onError,tagName)}}const coll=resolveCollection(CN2,ctx,token,onError,tagName,tag);const res=tag.resolve?.(coll,msg=>onError(tagToken,"TAG_RESOLVE_FAILED",msg),ctx.options)??coll;const node=isNode(res)?res:new Scalar(res);node.range=coll.range;node.tag=tagName;if(tag?.format)node.format=tag.format;return node}function resolveBlockScalar(scalar,strict,onError){const start=scalar.offset;const header=parseBlockScalarHeader(scalar,strict,onError);if(!header)return{value:"",type:null,comment:"",range:[start,start,start]};const type=header.mode===">"?Scalar.BLOCK_FOLDED:Scalar.BLOCK_LITERAL;const lines=scalar.source?splitLines(scalar.source):[];let chompStart=lines.length;for(let i=lines.length-1;i>=0;--i){const content=lines[i][1];if(content===""||content==="\r")chompStart=i;else break}if(chompStart===0){const value2=header.chomp==="+"&&lines.length>0?"\n".repeat(Math.max(1,lines.length-1)):"";let end2=start+header.length;if(scalar.source)end2+=scalar.source.length;return{value:value2,type,comment:header.comment,range:[start,end2,end2]}}let trimIndent=scalar.indent+header.indent;let offset=scalar.offset+header.length;let contentStart=0;for(let i=0;itrimIndent)trimIndent=indent.length}else{if(indent.length=chompStart;--i){if(lines[i][0].length>trimIndent)chompStart=i+1}let value="";let sep="";let prevMoreIndented=false;for(let i=0;itrimIndent||content[0]===" "){if(sep===" ")sep="\n";else if(!prevMoreIndented&&sep==="\n")sep="\n\n";value+=sep+indent.slice(trimIndent)+content;sep="\n";prevMoreIndented=true}else if(content===""){if(sep==="\n")value+="\n";else sep="\n"}else{value+=sep+content;sep=" ";prevMoreIndented=false}}switch(header.chomp){case"-":break;case"+":for(let i=chompStart;ionError(offset+rel,code,msg);switch(type){case"scalar":_type=Scalar.PLAIN;value=plainValue(source,_onError);break;case"single-quoted-scalar":_type=Scalar.QUOTE_SINGLE;value=singleQuotedValue(source,_onError);break;case"double-quoted-scalar":_type=Scalar.QUOTE_DOUBLE;value=doubleQuotedValue(source,_onError);break;default:onError(scalar,"UNEXPECTED_TOKEN",`Expected a flow scalar value, but found: ${type}`);return{value:"",type:null,comment:"",range:[offset,offset+source.length,offset+source.length]}}const valueEnd=offset+source.length;const re=resolveEnd(end,valueEnd,strict,onError);return{value,type:_type,comment:re.comment,range:[offset,valueEnd,re.offset]}}function plainValue(source,onError){let badChar="";switch(source[0]){case" ":badChar="a tab character";break;case",":badChar="flow indicator character ,";break;case"%":badChar="directive indicator character %";break;case"|":case">":{badChar=`block scalar indicator ${source[0]}`;break}case"@":case"`":{badChar=`reserved character ${source[0]}`;break}}if(badChar)onError(0,"BAD_SCALAR_START",`Plain value cannot start with ${badChar}`);return foldLines(source)}function singleQuotedValue(source,onError){if(source[source.length-1]!=="'"||source.length===1)onError(source.length,"MISSING_CHAR","Missing closing 'quote");return foldLines(source.slice(1,-1)).replace(/''/g,"'")}function foldLines(source){let first,line;try{first=new RegExp("(.*?)(?wsStart?source.slice(wsStart,i+1):ch}else{res+=ch}}if(source[source.length-1]!=='"'||source.length===1)onError(source.length,"MISSING_CHAR",'Missing closing "quote');return res}function foldNewline(source,offset){let fold="";let ch=source[offset+1];while(ch===" "||ch===" "||ch==="\n"||ch==="\r"){if(ch==="\r"&&source[offset+2]!=="\n")break;if(ch==="\n")fold+="\n";offset+=1;ch=source[offset+1]}if(!fold)fold=" ";return{fold,offset}}var escapeCodes={"0":"\0",a:"\x07",b:"\b",e:"\x1B",f:"\f",n:"\n",r:"\r",t:" ",v:"\v",N:"\x85",_:"\xA0",L:"\u2028",P:"\u2029"," ":" ",'"':'"',"/":"/","\\":"\\"," ":" "};function parseCharCode(source,offset,length,onError){const cc=source.substr(offset,length);const ok=cc.length===length&&/^[0-9a-fA-F]+$/.test(cc);const code=ok?parseInt(cc,16):NaN;if(isNaN(code)){const raw=source.substr(offset-2,length+2);onError(offset-2,"BAD_DQ_ESCAPE",`Invalid escape sequence ${raw}`);return raw}return String.fromCodePoint(code)}function composeScalar(ctx,token,tagToken,onError){const{value,type,comment,range}=token.type==="block-scalar"?resolveBlockScalar(token,ctx.options.strict,onError):resolveFlowScalar(token,ctx.options.strict,onError);const tagName=tagToken?ctx.directives.tagName(tagToken.source,msg=>onError(tagToken,"TAG_RESOLVE_FAILED",msg)):null;const tag=tagToken&&tagName?findScalarTagByName(ctx.schema,value,tagName,tagToken,onError):token.type==="scalar"?findScalarTagByTest(ctx,value,token,onError):ctx.schema[SCALAR];let scalar;try{const res=tag.resolve(value,msg=>onError(tagToken??token,"TAG_RESOLVE_FAILED",msg),ctx.options);scalar=isScalar(res)?res:new Scalar(res)}catch(error){const msg=error instanceof Error?error.message:String(error);onError(tagToken??token,"TAG_RESOLVE_FAILED",msg);scalar=new Scalar(value)}scalar.range=range;scalar.source=value;if(type)scalar.type=type;if(tagName)scalar.tag=tagName;if(tag.format)scalar.format=tag.format;if(comment)scalar.comment=comment;return scalar}function findScalarTagByName(schema4,value,tagName,tagToken,onError){if(tagName==="!")return schema4[SCALAR];const matchWithTest=[];for(const tag of schema4.tags){if(!tag.collection&&tag.tag===tagName){if(tag.default&&tag.test)matchWithTest.push(tag);else return tag}}for(const tag of matchWithTest)if(tag.test?.test(value))return tag;const kt=schema4.knownTags[tagName];if(kt&&!kt.collection){schema4.tags.push(Object.assign({},kt,{default:false,test:void 0}));return kt}onError(tagToken,"TAG_RESOLVE_FAILED",`Unresolved tag: ${tagName}`,tagName!=="tag:yaml.org,2002:str");return schema4[SCALAR]}function findScalarTagByTest({directives,schema:schema4},value,token,onError){const tag=schema4.tags.find(tag2=>tag2.default&&tag2.test?.test(value))||schema4[SCALAR];if(schema4.compat){const compat=schema4.compat.find(tag2=>tag2.default&&tag2.test?.test(value))??schema4[SCALAR];if(tag.tag!==compat.tag){const ts=directives.tagString(tag.tag);const cs=directives.tagString(compat.tag);const msg=`Value may be parsed as either ${ts} or ${cs}`;onError(token,"TAG_RESOLVE_FAILED",msg,true)}}return tag}function emptyScalarPosition(offset,before,pos){if(before){if(pos===null)pos=before.length;for(let i=pos-1;i>=0;--i){let st=before[i];switch(st.type){case"space":case"comment":case"newline":offset-=st.source.length;continue}st=before[++i];while(st?.type==="space"){offset+=st.source.length;st=before[++i]}break}}return offset}var CN={composeNode,composeEmptyNode};function composeNode(ctx,token,props,onError){const{spaceBefore,comment,anchor,tag}=props;let node;let isSrcToken=true;switch(token.type){case"alias":node=composeAlias(ctx,token,onError);if(anchor||tag)onError(token,"ALIAS_PROPS","An alias node must not specify any properties");break;case"scalar":case"single-quoted-scalar":case"double-quoted-scalar":case"block-scalar":node=composeScalar(ctx,token,tag,onError);if(anchor)node.anchor=anchor.source.substring(1);break;case"block-map":case"block-seq":case"flow-collection":node=composeCollection(CN,ctx,token,tag,onError);if(anchor)node.anchor=anchor.source.substring(1);break;default:{const message=token.type==="error"?token.message:`Unsupported token (type: ${token.type})`;onError(token,"UNEXPECTED_TOKEN",message);node=composeEmptyNode(ctx,token.offset,void 0,null,props,onError);isSrcToken=false}}if(anchor&&node.anchor==="")onError(anchor,"BAD_ALIAS","Anchor cannot be an empty string");if(spaceBefore)node.spaceBefore=true;if(comment){if(token.type==="scalar"&&token.source==="")node.comment=comment;else node.commentBefore=comment}if(ctx.options.keepSourceTokens&&isSrcToken)node.srcToken=token;return node}function composeEmptyNode(ctx,offset,before,pos,{spaceBefore,comment,anchor,tag,end},onError){const token={type:"scalar",offset:emptyScalarPosition(offset,before,pos),indent:-1,source:""};const node=composeScalar(ctx,token,tag,onError);if(anchor){node.anchor=anchor.source.substring(1);if(node.anchor==="")onError(anchor,"BAD_ALIAS","Anchor cannot be an empty string")}if(spaceBefore)node.spaceBefore=true;if(comment){node.comment=comment;node.range[2]=end}return node}function composeAlias({options},{offset,source,end},onError){const alias=new Alias(source.substring(1));if(alias.source==="")onError(offset,"BAD_ALIAS","Alias cannot be an empty string");if(alias.source.endsWith(":"))onError(offset+source.length-1,"BAD_ALIAS","Alias ending in : is ambiguous",true);const valueEnd=offset+source.length;const re=resolveEnd(end,valueEnd,options.strict,onError);alias.range=[offset,valueEnd,re.offset];if(re.comment)alias.comment=re.comment;return alias}function composeDoc(options,directives,{offset,start,value,end},onError){const opts=Object.assign({_directives:directives},options);const doc=new Document(void 0,opts);const ctx={atRoot:true,directives:doc.directives,options:doc.options,schema:doc.schema};const props=resolveProps(start,{indicator:"doc-start",next:value??end?.[0],offset,onError,startOnNewline:true});if(props.found){doc.directives.docStart=true;if(value&&(value.type==="block-map"||value.type==="block-seq")&&!props.hasNewline)onError(props.end,"MISSING_CHAR","Block collection cannot start on same line with directives-end marker")}doc.contents=value?composeNode(ctx,value,props,onError):composeEmptyNode(ctx,props.end,start,null,props,onError);const contentEnd=doc.contents.range[2];const re=resolveEnd(end,contentEnd,false,onError);if(re.comment)doc.comment=re.comment;doc.range=[offset,contentEnd,re.offset];return doc}function getErrorPos(src){if(typeof src==="number")return[src,src+1];if(Array.isArray(src))return src.length===2?src:[src[0],src[1]];const{offset,source}=src;return[offset,offset+(typeof source==="string"?source.length:1)]}function parsePrelude(prelude){let comment="";let atComment=false;let afterEmptyLine=false;for(let i=0;i{const pos=getErrorPos(source);if(warning)this.warnings.push(new YAMLWarning(pos,code,message));else this.errors.push(new YAMLParseError(pos,code,message))};this.directives=new Directives({version:options.version||"1.2"});this.options=options}decorate(doc,afterDoc){const{comment,afterEmptyLine}=parsePrelude(this.prelude);if(comment){const dc=doc.contents;if(afterDoc){doc.comment=doc.comment?`${doc.comment} ${comment}`:comment}else if(afterEmptyLine||doc.directives.docStart||!dc){doc.commentBefore=comment}else if(isCollection(dc)&&!dc.flow&&dc.items.length>0){let it=dc.items[0];if(isPair(it))it=it.key;const cb=it.commentBefore;it.commentBefore=cb?`${comment} ${cb}`:comment}else{const cb=dc.commentBefore;dc.commentBefore=cb?`${comment} ${cb}`:comment}}if(afterDoc){Array.prototype.push.apply(doc.errors,this.errors);Array.prototype.push.apply(doc.warnings,this.warnings)}else{doc.errors=this.errors;doc.warnings=this.warnings}this.prelude=[];this.errors=[];this.warnings=[]}streamInfo(){return{comment:parsePrelude(this.prelude).comment,directives:this.directives,errors:this.errors,warnings:this.warnings}}*compose(tokens,forceDoc=false,endOffset=-1){for(const token of tokens)yield*this.next(token);yield*this.end(forceDoc,endOffset)}*next(token){switch(token.type){case"directive":this.directives.add(token.source,(offset,message,warning)=>{const pos=getErrorPos(token);pos[0]+=offset;this.onError(pos,"BAD_DIRECTIVE",message,warning)});this.prelude.push(token.source);this.atDirectives=true;break;case"document":{const doc=composeDoc(this.options,this.directives,token,this.onError);if(this.atDirectives&&!doc.directives.docStart)this.onError(token,"MISSING_CHAR","Missing directives-end/doc-start indicator line");this.decorate(doc,false);if(this.doc)yield this.doc;this.doc=doc;this.atDirectives=false;break}case"byte-order-mark":case"space":break;case"comment":case"newline":this.prelude.push(token.source);break;case"error":{const msg=token.source?`${token.message}: ${JSON.stringify(token.source)}`:token.message;const error=new YAMLParseError(getErrorPos(token),"UNEXPECTED_TOKEN",msg);if(this.atDirectives||!this.doc)this.errors.push(error);else this.doc.errors.push(error);break}case"doc-end":{if(!this.doc){const msg="Unexpected doc-end without preceding document";this.errors.push(new YAMLParseError(getErrorPos(token),"UNEXPECTED_TOKEN",msg));break}this.doc.directives.docEnd=true;const end=resolveEnd(token.end,token.offset+token.source.length,this.doc.options.strict,this.onError);this.decorate(this.doc,true);if(end.comment){const dc=this.doc.comment;this.doc.comment=dc?`${dc} ${end.comment}`:end.comment}this.doc.range[2]=end.offset;break}default:this.errors.push(new YAMLParseError(getErrorPos(token),"UNEXPECTED_TOKEN",`Unsupported token ${token.type}`))}}*end(forceDoc=false,endOffset=-1){if(this.doc){this.decorate(this.doc,true);yield this.doc;this.doc=null}else if(forceDoc){const opts=Object.assign({_directives:this.directives},this.options);const doc=new Document(void 0,opts);if(this.atDirectives)this.onError(endOffset,"MISSING_CHAR","Missing directives-end indicator line");doc.range=[0,endOffset,endOffset];this.decorate(doc,false);yield doc}}};var BREAK2=Symbol("break visit");var SKIP2=Symbol("skip children");var REMOVE2=Symbol("remove item");function visit2(cst,visitor){if("type"in cst&&cst.type==="document")cst={start:cst.start,value:cst.value};_visit(Object.freeze([]),cst,visitor)}visit2.BREAK=BREAK2;visit2.SKIP=SKIP2;visit2.REMOVE=REMOVE2;visit2.itemAtPath=(cst,path)=>{let item=cst;for(const[field,index]of path){const tok=item?.[field];if(tok&&"items"in tok){item=tok.items[index]}else return void 0}return item};visit2.parentCollection=(cst,path)=>{const parent=visit2.itemAtPath(cst,path.slice(0,-1));const field=path[path.length-1][0];const coll=parent?.[field];if(coll&&"items"in coll)return coll;throw new Error("Parent collection not found")};function _visit(path,item,visitor){let ctrl=visitor(item,path);if(typeof ctrl==="symbol")return ctrl;for(const field of["key","value"]){const token=item[field];if(token&&"items"in token){for(let i=0;i":return"block-scalar-header"}return null}function isEmpty(ch){switch(ch){case void 0:case" ":case"\n":case"\r":case" ":return true;default:return false}}var hexDigits="0123456789ABCDEFabcdef".split("");var tagChars="0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-#;/?:@&=+$_.!~*'()".split("");var invalidFlowScalarChars=",[]{}".split("");var invalidAnchorChars=" ,[]{}\n\r ".split("");var isNotAnchorChar=ch=>!ch||invalidAnchorChars.includes(ch);var Lexer=class{constructor(){this.atEnd=false;this.blockScalarIndent=-1;this.blockScalarKeep=false;this.buffer="";this.flowKey=false;this.flowLevel=0;this.indentNext=0;this.indentValue=0;this.lineEndPos=null;this.next=null;this.pos=0}*lex(source,incomplete=false){if(source){this.buffer=this.buffer?this.buffer+source:source;this.lineEndPos=null}this.atEnd=!incomplete;let next=this.next??"stream";while(next&&(incomplete||this.hasChars(1)))next=yield*this.parseNext(next)}atLineEnd(){let i=this.pos;let ch=this.buffer[i];while(ch===" "||ch===" ")ch=this.buffer[++i];if(!ch||ch==="#"||ch==="\n")return true;if(ch==="\r")return this.buffer[i+1]==="\n";return false}charAt(n){return this.buffer[this.pos+n]}continueScalar(offset){let ch=this.buffer[offset];if(this.indentNext>0){let indent=0;while(ch===" ")ch=this.buffer[++indent+offset];if(ch==="\r"){const next=this.buffer[indent+offset+1];if(next==="\n"||!next&&!this.atEnd)return offset+indent+1}return ch==="\n"||indent>=this.indentNext||!ch&&!this.atEnd?offset+indent:-1}if(ch==="-"||ch==="."){const dt=this.buffer.substr(offset,3);if((dt==="---"||dt==="...")&&isEmpty(this.buffer[offset+3]))return-1}return offset}getLine(){let end=this.lineEndPos;if(typeof end!=="number"||end!==-1&&endthis.indentValue&&!isEmpty(this.charAt(1)))this.indentNext=this.indentValue;return yield*this.parseBlockStart()}*parseBlockStart(){const[ch0,ch1]=this.peek(2);if(!ch1&&!this.atEnd)return this.setNext("block-start");if((ch0==="-"||ch0==="?"||ch0===":")&&isEmpty(ch1)){const n=(yield*this.pushCount(1))+(yield*this.pushSpaces(true));this.indentNext=this.indentValue+1;this.indentValue+=n;return yield*this.parseBlockStart()}return"doc"}*parseDocument(){yield*this.pushSpaces(true);const line=this.getLine();if(line===null)return this.setNext("doc");let n=yield*this.pushIndicators();switch(line[n]){case"#":yield*this.pushCount(line.length-n);case void 0:yield*this.pushNewline();return yield*this.parseLineStart();case"{":case"[":yield*this.pushCount(1);this.flowKey=false;this.flowLevel=1;return"flow";case"}":case"]":yield*this.pushCount(1);return"doc";case"*":yield*this.pushUntil(isNotAnchorChar);return"doc";case'"':case"'":return yield*this.parseQuotedScalar();case"|":case">":n+=yield*this.parseBlockScalarHeader();n+=yield*this.pushSpaces(true);yield*this.pushCount(line.length-n);yield*this.pushNewline();return yield*this.parseBlockScalar();default:return yield*this.parsePlainScalar()}}*parseFlowCollection(){let nl,sp;let indent=-1;do{nl=yield*this.pushNewline();if(nl>0){sp=yield*this.pushSpaces(false);this.indentValue=indent=sp}else{sp=0}sp+=yield*this.pushSpaces(true)}while(nl+sp>0);const line=this.getLine();if(line===null)return this.setNext("flow");if(indent!==-1&&indent"0"&&ch<="9")this.blockScalarIndent=Number(ch)-1;else if(ch!=="-")break}return yield*this.pushUntil(ch=>isEmpty(ch)||ch==="#")}*parseBlockScalar(){let nl=this.pos-1;let indent=0;let ch;loop:for(let i=this.pos;ch=this.buffer[i];++i){switch(ch){case" ":indent+=1;break;case"\n":nl=i;indent=0;break;case"\r":{const next=this.buffer[i+1];if(!next&&!this.atEnd)return this.setNext("block-scalar");if(next==="\n")break}default:break loop}}if(!ch&&!this.atEnd)return this.setNext("block-scalar");if(indent>=this.indentNext){if(this.blockScalarIndent===-1)this.indentNext=indent;else this.indentNext+=this.blockScalarIndent;do{const cs=this.continueScalar(nl+1);if(cs===-1)break;nl=this.buffer.indexOf("\n",cs)}while(nl!==-1);if(nl===-1){if(!this.atEnd)return this.setNext("block-scalar");nl=this.buffer.length}}if(!this.blockScalarKeep){do{let i=nl-1;let ch2=this.buffer[i];if(ch2==="\r")ch2=this.buffer[--i];const lastChar=i;while(ch2===" "||ch2===" ")ch2=this.buffer[--i];if(ch2==="\n"&&i>=this.pos&&i+1+indent>lastChar)nl=i;else break}while(true)}yield SCALAR2;yield*this.pushToIndex(nl+1,true);return yield*this.parseLineStart()}*parsePlainScalar(){const inFlow=this.flowLevel>0;let end=this.pos-1;let i=this.pos-1;let ch;while(ch=this.buffer[++i]){if(ch===":"){const next=this.buffer[i+1];if(isEmpty(next)||inFlow&&next===",")break;end=i}else if(isEmpty(ch)){let next=this.buffer[i+1];if(ch==="\r"){if(next==="\n"){i+=1;ch="\n";next=this.buffer[i+1]}else end=i}if(next==="#"||inFlow&&invalidFlowScalarChars.includes(next))break;if(ch==="\n"){const cs=this.continueScalar(i+1);if(cs===-1)break;i=Math.max(i,cs-2)}}else{if(inFlow&&invalidFlowScalarChars.includes(ch))break;end=i}}if(!ch&&!this.atEnd)return this.setNext("plain-scalar");yield SCALAR2;yield*this.pushToIndex(end+1,true);return inFlow?"flow":"doc"}*pushCount(n){if(n>0){yield this.buffer.substr(this.pos,n);this.pos+=n;return n}return 0}*pushToIndex(i,allowEmpty){const s=this.buffer.slice(this.pos,i);if(s){yield s;this.pos+=s.length;return s.length}else if(allowEmpty)yield"";return 0}*pushIndicators(){switch(this.charAt(0)){case"!":return(yield*this.pushTag())+(yield*this.pushSpaces(true))+(yield*this.pushIndicators());case"&":return(yield*this.pushUntil(isNotAnchorChar))+(yield*this.pushSpaces(true))+(yield*this.pushIndicators());case"-":case"?":case":":{const inFlow=this.flowLevel>0;const ch1=this.charAt(1);if(isEmpty(ch1)||inFlow&&invalidFlowScalarChars.includes(ch1)){if(!inFlow)this.indentNext=this.indentValue+1;else if(this.flowKey)this.flowKey=false;return(yield*this.pushCount(1))+(yield*this.pushSpaces(true))+(yield*this.pushIndicators())}}}return 0}*pushTag(){if(this.charAt(1)==="<"){let i=this.pos+2;let ch=this.buffer[i];while(!isEmpty(ch)&&ch!==">")ch=this.buffer[++i];return yield*this.pushToIndex(ch===">"?i+1:i,false)}else{let i=this.pos+1;let ch=this.buffer[i];while(ch){if(tagChars.includes(ch))ch=this.buffer[++i];else if(ch==="%"&&hexDigits.includes(this.buffer[i+1])&&hexDigits.includes(this.buffer[i+2])){ch=this.buffer[i+=3]}else break}return yield*this.pushToIndex(i,false)}}*pushNewline(){const ch=this.buffer[this.pos];if(ch==="\n")return yield*this.pushCount(1);else if(ch==="\r"&&this.charAt(1)==="\n")return yield*this.pushCount(2);else return 0}*pushSpaces(allowTabs){let i=this.pos-1;let ch;do{ch=this.buffer[++i]}while(ch===" "||allowTabs&&ch===" ");const n=i-this.pos;if(n>0){yield this.buffer.substr(this.pos,n);this.pos=i}return n}*pushUntil(test){let i=this.pos;let ch=this.buffer[i];while(!test(ch))ch=this.buffer[++i];return yield*this.pushToIndex(i,false)}};var LineCounter=class{constructor(){this.lineStarts=[];this.addNewLine=offset=>this.lineStarts.push(offset);this.linePos=offset=>{let low=0;let high=this.lineStarts.length;while(low>1;if(this.lineStarts[mid]=0){switch(prev[i].type){case"doc-start":case"explicit-key-ind":case"map-value-ind":case"seq-item-ind":case"newline":break loop}}while(prev[++i]?.type==="space"){}return prev.splice(i,prev.length)}function fixFlowSeqItems(fc){if(fc.start.type==="flow-seq-start"){for(const it of fc.items){if(it.sep&&!it.value&&!includesToken(it.start,"explicit-key-ind")&&!includesToken(it.sep,"map-value-ind")){if(it.key)it.value=it.key;delete it.key;if(isFlowToken(it.value)){if(it.value.end)Array.prototype.push.apply(it.value.end,it.sep);else it.value.end=it.sep}else Array.prototype.push.apply(it.start,it.sep);delete it.sep}}}}var Parser=class{constructor(onNewLine){this.atNewLine=true;this.atScalar=false;this.indent=0;this.offset=0;this.onKeyLine=false;this.stack=[];this.source="";this.type="";this.lexer=new Lexer;this.onNewLine=onNewLine}*parse(source,incomplete=false){if(this.onNewLine&&this.offset===0)this.onNewLine(0);for(const lexeme of this.lexer.lex(source,incomplete))yield*this.next(lexeme);if(!incomplete)yield*this.end()}*next(source){this.source=source;if(this.atScalar){this.atScalar=false;yield*this.step();this.offset+=source.length;return}const type=tokenType(source);if(!type){const message=`Not a YAML token: ${source}`;yield*this.pop({type:"error",offset:this.offset,message,source});this.offset+=source.length}else if(type==="scalar"){this.atNewLine=false;this.atScalar=true;this.type="scalar"}else{this.type=type;yield*this.step();switch(type){case"newline":this.atNewLine=true;this.indent=0;if(this.onNewLine)this.onNewLine(this.offset+source.length);break;case"space":if(this.atNewLine&&source[0]===" ")this.indent+=source.length;break;case"explicit-key-ind":case"map-value-ind":case"seq-item-ind":if(this.atNewLine)this.indent+=source.length;break;case"doc-mode":case"flow-error-end":return;default:this.atNewLine=false}this.offset+=source.length}}*end(){while(this.stack.length>0)yield*this.pop()}get sourceToken(){const st={type:this.type,offset:this.offset,indent:this.indent,source:this.source};return st}*step(){const top=this.peek(1);if(this.type==="doc-end"&&(!top||top.type!=="doc-end")){while(this.stack.length>0)yield*this.pop();this.stack.push({type:"doc-end",offset:this.offset,source:this.source});return}if(!top)return yield*this.stream();switch(top.type){case"document":return yield*this.document(top);case"alias":case"scalar":case"single-quoted-scalar":case"double-quoted-scalar":return yield*this.scalar(top);case"block-scalar":return yield*this.blockScalar(top);case"block-map":return yield*this.blockMap(top);case"block-seq":return yield*this.blockSequence(top);case"flow-collection":return yield*this.flowCollection(top);case"doc-end":return yield*this.documentEnd(top)}yield*this.pop()}peek(n){return this.stack[this.stack.length-n]}*pop(error){const token=error??this.stack.pop();if(!token){const message="Tried to pop an empty stack";yield{type:"error",offset:this.offset,source:"",message}}else if(this.stack.length===0){yield token}else{const top=this.peek(1);if(token.type==="block-scalar"){token.indent="indent"in top?top.indent:0}else if(token.type==="flow-collection"&&top.type==="document"){token.indent=0}if(token.type==="flow-collection")fixFlowSeqItems(token);switch(top.type){case"document":top.value=token;break;case"block-scalar":top.props.push(token);break;case"block-map":{const it=top.items[top.items.length-1];if(it.value){top.items.push({start:[],key:token,sep:[]});this.onKeyLine=true;return}else if(it.sep){it.value=token}else{Object.assign(it,{key:token,sep:[]});this.onKeyLine=!includesToken(it.start,"explicit-key-ind");return}break}case"block-seq":{const it=top.items[top.items.length-1];if(it.value)top.items.push({start:[],value:token});else it.value=token;break}case"flow-collection":{const it=top.items[top.items.length-1];if(!it||it.value)top.items.push({start:[],key:token,sep:[]});else if(it.sep)it.value=token;else Object.assign(it,{key:token,sep:[]});return}default:yield*this.pop();yield*this.pop(token)}if((top.type==="document"||top.type==="block-map"||top.type==="block-seq")&&(token.type==="block-map"||token.type==="block-seq")){const last=token.items[token.items.length-1];if(last&&!last.sep&&!last.value&&last.start.length>0&&findNonEmptyIndex(last.start)===-1&&(token.indent===0||last.start.every(st=>st.type!=="comment"||st.indent=map2.indent){const atNextItem=!this.onKeyLine&&this.indent===map2.indent&&it.sep;let start=[];if(atNextItem&&it.sep&&!it.value){const nl=[];for(let i=0;imap2.indent)nl.length=0;break;default:nl.length=0}}if(nl.length>=2)start=it.sep.splice(nl[1])}switch(this.type){case"anchor":case"tag":if(atNextItem||it.value){start.push(this.sourceToken);map2.items.push({start});this.onKeyLine=true}else if(it.sep){it.sep.push(this.sourceToken)}else{it.start.push(this.sourceToken)}return;case"explicit-key-ind":if(!it.sep&&!includesToken(it.start,"explicit-key-ind")){it.start.push(this.sourceToken)}else if(atNextItem||it.value){start.push(this.sourceToken);map2.items.push({start})}else{this.stack.push({type:"block-map",offset:this.offset,indent:this.indent,items:[{start:[this.sourceToken]}]})}this.onKeyLine=true;return;case"map-value-ind":if(includesToken(it.start,"explicit-key-ind")){if(!it.sep){if(includesToken(it.start,"newline")){Object.assign(it,{key:null,sep:[this.sourceToken]})}else{const start2=getFirstKeyStartProps(it.start);this.stack.push({type:"block-map",offset:this.offset,indent:this.indent,items:[{start:start2,key:null,sep:[this.sourceToken]}]})}}else if(it.value){map2.items.push({start:[],key:null,sep:[this.sourceToken]})}else if(includesToken(it.sep,"map-value-ind")){this.stack.push({type:"block-map",offset:this.offset,indent:this.indent,items:[{start,key:null,sep:[this.sourceToken]}]})}else if(isFlowToken(it.key)&&!includesToken(it.sep,"newline")){const start2=getFirstKeyStartProps(it.start);const key=it.key;const sep=it.sep;sep.push(this.sourceToken);delete it.key,delete it.sep;this.stack.push({type:"block-map",offset:this.offset,indent:this.indent,items:[{start:start2,key,sep}]})}else if(start.length>0){it.sep=it.sep.concat(start,this.sourceToken)}else{it.sep.push(this.sourceToken)}}else{if(!it.sep){Object.assign(it,{key:null,sep:[this.sourceToken]})}else if(it.value||atNextItem){map2.items.push({start,key:null,sep:[this.sourceToken]})}else if(includesToken(it.sep,"map-value-ind")){this.stack.push({type:"block-map",offset:this.offset,indent:this.indent,items:[{start:[],key:null,sep:[this.sourceToken]}]})}else{it.sep.push(this.sourceToken)}}this.onKeyLine=true;return;case"alias":case"scalar":case"single-quoted-scalar":case"double-quoted-scalar":{const fs=this.flowScalar(this.type);if(atNextItem||it.value){map2.items.push({start,key:fs,sep:[]});this.onKeyLine=true}else if(it.sep){this.stack.push(fs)}else{Object.assign(it,{key:fs,sep:[]});this.onKeyLine=true}return}default:{const bv=this.startBlockValue(map2);if(bv){if(atNextItem&&bv.type!=="block-seq"&&includesToken(it.start,"explicit-key-ind")){map2.items.push({start})}this.stack.push(bv);return}}}}yield*this.pop();yield*this.step()}*blockSequence(seq2){const it=seq2.items[seq2.items.length-1];switch(this.type){case"newline":if(it.value){const end="end"in it.value?it.value.end:void 0;const last=Array.isArray(end)?end[end.length-1]:void 0;if(last?.type==="comment")end?.push(this.sourceToken);else seq2.items.push({start:[this.sourceToken]})}else it.start.push(this.sourceToken);return;case"space":case"comment":if(it.value)seq2.items.push({start:[this.sourceToken]});else{if(this.atIndentedComment(it.start,seq2.indent)){const prev=seq2.items[seq2.items.length-2];const end=prev?.value?.end;if(Array.isArray(end)){Array.prototype.push.apply(end,it.start);end.push(this.sourceToken);seq2.items.pop();return}}it.start.push(this.sourceToken)}return;case"anchor":case"tag":if(it.value||this.indent<=seq2.indent)break;it.start.push(this.sourceToken);return;case"seq-item-ind":if(this.indent!==seq2.indent)break;if(it.value||includesToken(it.start,"seq-item-ind"))seq2.items.push({start:[this.sourceToken]});else it.start.push(this.sourceToken);return}if(this.indent>seq2.indent){const bv=this.startBlockValue(seq2);if(bv){this.stack.push(bv);return}}yield*this.pop();yield*this.step()}*flowCollection(fc){const it=fc.items[fc.items.length-1];if(this.type==="flow-error-end"){let top;do{yield*this.pop();top=this.peek(1)}while(top&&top.type==="flow-collection")}else if(fc.end.length===0){switch(this.type){case"comma":case"explicit-key-ind":if(!it||it.sep)fc.items.push({start:[this.sourceToken]});else it.start.push(this.sourceToken);return;case"map-value-ind":if(!it||it.value)fc.items.push({start:[],key:null,sep:[this.sourceToken]});else if(it.sep)it.sep.push(this.sourceToken);else Object.assign(it,{key:null,sep:[this.sourceToken]});return;case"space":case"comment":case"newline":case"anchor":case"tag":if(!it||it.value)fc.items.push({start:[this.sourceToken]});else if(it.sep)it.sep.push(this.sourceToken);else it.start.push(this.sourceToken);return;case"alias":case"scalar":case"single-quoted-scalar":case"double-quoted-scalar":{const fs=this.flowScalar(this.type);if(!it||it.value)fc.items.push({start:[],key:fs,sep:[]});else if(it.sep)this.stack.push(fs);else Object.assign(it,{key:fs,sep:[]});return}case"flow-map-end":case"flow-seq-end":fc.end.push(this.sourceToken);return}const bv=this.startBlockValue(fc);if(bv)this.stack.push(bv);else{yield*this.pop();yield*this.step()}}else{const parent=this.peek(2);if(parent.type==="block-map"&&(this.type==="map-value-ind"&&parent.indent===fc.indent||this.type==="newline"&&!parent.items[parent.items.length-1].sep)){yield*this.pop();yield*this.step()}else if(this.type==="map-value-ind"&&parent.type!=="flow-collection"){const prev=getPrevProps(parent);const start=getFirstKeyStartProps(prev);fixFlowSeqItems(fc);const sep=fc.end.splice(1,fc.end.length);sep.push(this.sourceToken);const map2={type:"block-map",offset:fc.offset,indent:fc.indent,items:[{start,key:fc,sep}]};this.onKeyLine=true;this.stack[this.stack.length-1]=map2}else{yield*this.lineEnd(fc)}}}flowScalar(type){if(this.onNewLine){let nl=this.source.indexOf("\n")+1;while(nl!==0){this.onNewLine(this.offset+nl);nl=this.source.indexOf("\n",nl)+1}}return{type,offset:this.offset,indent:this.indent,source:this.source}}startBlockValue(parent){switch(this.type){case"alias":case"scalar":case"single-quoted-scalar":case"double-quoted-scalar":return this.flowScalar(this.type);case"block-scalar-header":return{type:"block-scalar",offset:this.offset,indent:this.indent,props:[this.sourceToken],source:""};case"flow-map-start":case"flow-seq-start":return{type:"flow-collection",offset:this.offset,indent:this.indent,start:this.sourceToken,items:[],end:[]};case"seq-item-ind":return{type:"block-seq",offset:this.offset,indent:this.indent,items:[{start:[this.sourceToken]}]};case"explicit-key-ind":{this.onKeyLine=true;const prev=getPrevProps(parent);const start=getFirstKeyStartProps(prev);start.push(this.sourceToken);return{type:"block-map",offset:this.offset,indent:this.indent,items:[{start}]}}case"map-value-ind":{this.onKeyLine=true;const prev=getPrevProps(parent);const start=getFirstKeyStartProps(prev);return{type:"block-map",offset:this.offset,indent:this.indent,items:[{start,key:null,sep:[this.sourceToken]}]}}}return null}atIndentedComment(start,indent){if(this.type!=="comment")return false;if(this.indent<=indent)return false;return start.every(st=>st.type==="newline"||st.type==="space")}*documentEnd(docEnd){if(this.type!=="doc-mode"){if(docEnd.end)docEnd.end.push(this.sourceToken);else docEnd.end=[this.sourceToken];if(this.type==="newline")yield*this.pop()}}*lineEnd(token){switch(this.type){case"comma":case"doc-start":case"doc-end":case"flow-seq-end":case"flow-map-end":case"map-value-ind":yield*this.pop();yield*this.step();break;case"newline":this.onKeyLine=false;case"space":case"comment":default:if(token.end)token.end.push(this.sourceToken);else token.end=[this.sourceToken];if(this.type==="newline")yield*this.pop()}}};function parseOptions(options){const prettyErrors=options.prettyErrors!==false;const lineCounter=options.lineCounter||prettyErrors&&new LineCounter||null;return{lineCounter,prettyErrors}}function parseDocument(source,options={}){const{lineCounter,prettyErrors}=parseOptions(options);const parser=new Parser(lineCounter?.addNewLine);const composer=new Composer(options);let doc=null;for(const _doc of composer.compose(parser.parse(source),true,source.length)){if(!doc)doc=_doc;else if(doc.options.logLevel!=="silent"){doc.errors.push(new YAMLParseError(_doc.range.slice(0,2),"MULTIPLE_DOCS","Source contains multiple documents; please use YAML.parseAllDocuments()"));break}}if(prettyErrors&&lineCounter){doc.errors.forEach(prettifyError(source,lineCounter));doc.warnings.forEach(prettifyError(source,lineCounter))}return doc}function parse(src,reviver,options){let _reviver=void 0;if(typeof reviver==="function"){_reviver=reviver}else if(options===void 0&&reviver&&typeof reviver==="object"){options=reviver}const doc=parseDocument(src,options);if(!doc)return null;doc.warnings.forEach(warning=>warn(doc.options.logLevel,warning));if(doc.errors.length>0){if(doc.options.logLevel!=="silent")throw doc.errors[0];else doc.errors=[]}return doc.toJS(Object.assign({reviver:_reviver},options))}function stringify3(value,replacer,options){let _replacer=null;if(typeof replacer==="function"||Array.isArray(replacer)){_replacer=replacer}else if(options===void 0&&replacer){options=replacer}if(typeof options==="string")options=options.length;if(typeof options==="number"){const indent=Math.round(options);options=indent<1?void 0:indent>8?{indent:8}:{indent}}if(value===void 0){const{keepUndefined}=options??replacer??{};if(!keepUndefined)return void 0}return new Document(value,_replacer,options).toString(options)}globalThis.YAML={parse,stringify:stringify3}; }() ================================================ FILE: npm/package.json ================================================ { "name": "fx", "version": "39.2.0", "bin": { "fx": "index.js" }, "files": [ "index.js" ], "scripts": { "test": "node test.js" }, "repository": "antonmedv/fx", "homepage": "https://fx.wtf", "description": "Command-line JSON viewer", "author": "Anton Medvedev ", "license": "MIT" } ================================================ FILE: npm/test.js ================================================ async function test(name, fn) { try { await fn(await import('node:assert/strict')) console.log(`✓ ${name}`) } catch (err) { console.error(`✗ ${name}`) throw err } } async function run(json, code = '') { const {spawnSync} = await import('node:child_process') return spawnSync(`printf -- '${typeof json === 'string' ? json : JSON.stringify(json)}' | node index.js ${code}`, { stdio: 'pipe', encoding: 'utf8', shell: true }) } async function runNoPipe(code = '') { const {spawnSync} = await import('node:child_process') return spawnSync(`node index.js ${code}`, { stdio: 'pipe', encoding: 'utf8', shell: true }) } void async function main() { await test('properly formatted', async t => { const {stdout} = await run([{'greeting': 'hello world'}]) t.deepEqual(stdout, '[\n {\n "greeting": "hello world"\n }\n]\n') }) await test('format - escape newline', async t => { const {stdout} = await run(`{"foo": "bar\\\\nbaz"}`) t.equal(stdout, '{\n "foo": "bar\\nbaz"\n}\n') }) await test('parseJson - valid json', async t => { const obj = {a: 2.3e100, b: 'str', c: null, d: false, e: [1, 2, 3]} const {stdout, stderr} = await run(obj) t.equal(stderr, '') t.equal(stdout, JSON.stringify(obj, null, 2) + '\n') }) await test('parseJson - invalid json', async t => { const {stderr, status} = await run('{invalid}') t.equal(status, 1) t.ok(stderr.includes('SyntaxError')) }) await test('parseJson - invalid number', async t => { const {stderr, status} = await run('{"num": 12.3.4}') t.equal(status, 1) t.ok(stderr.includes('SyntaxError')) }) await test('parseJson - string control chars', async t => { const {stderr, status} = await run('"\t"') t.equal(status, 1) t.ok(stderr.includes('SyntaxError')) }) await test('parseJson - numbers', async t => { t.equal((await run('1.2e300')).stdout, '1.2e+300\n') t.equal((await run('123456789012345678901234567890')).stdout, '123456789012345678901234567890\n') t.equal((await run('23')).stdout, '23\n') t.equal((await run('0')).stdout, '0\n') t.equal((await run('0e+2')).stdout, '0\n') t.equal((await run('0e+2')).stdout, '0\n') t.equal((await run('0.0')).stdout, '0\n') t.equal((await run('-0')).stdout, '0\n') t.equal((await run('2.3')).stdout, '2.3\n') t.equal((await run('2300e3')).stdout, '2300000\n') t.equal((await run('2300e+3')).stdout, '2300000\n') t.equal((await run('-2')).stdout, '-2\n') t.equal((await run('2e-3')).stdout, '0.002\n') t.equal((await run('2.3e-3')).stdout, '0.0023\n') }) await test('parseJson - object tailing comma', async t => { const {stdout} = await run('{"a": 1,}') t.equal(stdout, '{\n "a": 1\n}\n') }) await test('parseJson - array tailing comma', async t => { const {stdout} = await run('[1,]') t.equal(stdout, '[\n 1\n]\n') }) await test('parseJson - comments', async t => { const {stdout} = await run('/* comment */ [1 // comment\n]') t.equal(stdout, '[\n 1\n]\n') }) await test('parseYaml', async t => { const {stdout} = await run('- foo\n- bar', '--yaml') t.equal(stdout, '[\n "foo",\n "bar"\n]\n') }) await test('transform - anonymous function', async t => { const {stdout} = await run({'key': 'value'}, '\'function (x) { return x.key }\'') t.equal(stdout, 'value\n') }) await test('transform - arrow function', async t => { const {stdout} = await run({'key': 'value'}, '\'x => x.key\'') t.equal(stdout, 'value\n') }) await test('transform - arrow function with param brackets', async t => { const {stdout} = await run({'key': 'value'}, `'(x) => x.key'`) t.equal(stdout, 'value\n') }) await test('transform - this is json', async t => { const {stdout} = await run([1, 2, 3, 4, 5], `'this.map(x => x * this.length)'`) t.deepEqual(JSON.parse(stdout), [5, 10, 15, 20, 25]) }) await test('transform - chain works', async t => { const {stdout} = await run({'items': ['foo', 'bar']}, `'this.items' '.' 'x => x[1]'`) t.equal(stdout, 'bar\n') }) await test('transform - map works with func', async t => { const {stdout} = await run([{foo: 'bar'}], `'map(x => x.foo)'`) t.deepEqual(JSON.parse(stdout), ['bar']) }) await test('transform - map passes index', async t => { const {stdout} = await run([1, 2, 3], `'map((x, i) => x * i)'`) t.deepEqual(JSON.parse(stdout), [0, 2, 6]) }) await test('transform - @ works', async t => { const {stdout} = await run([1, 2, 3], `'@x * 2'`) t.deepEqual(JSON.parse(stdout), [2, 4, 6]) }) await test('transform - @ works with dot', async t => { const {stdout} = await run([{foo: 'bar'}], `@.foo`) t.deepEqual(JSON.parse(stdout), ['bar']) }) await test('transform - flat map works', async t => { const {stdout} = await run({master: {foo: [{bar: [{val: 1}]}]}}, '.master.foo[].bar[].val') t.deepEqual(JSON.parse(stdout), [1]) }) await test('transform - flat map works on the first level', async t => { const {stdout} = await run([{val: 1}, {val: 2}], '.[].val') t.deepEqual(JSON.parse(stdout), [1, 2]) }) await test('transform - sort & uniq', async t => { const {stdout} = await run([2, 2, 3, 1], `sort uniq`) t.deepEqual(JSON.parse(stdout), [1, 2, 3]) }) await test('transform - skip', async t => { const {stdout} = await run(42, `skip`) t.equal(stdout, '') }) await test('transform - invalid code argument', async t => { const json = {foo: 'bar'} const code = '".foo.toUpperCase("' const {stderr, status} = await run(json, code) t.equal(status, 1) t.ok(stderr.includes(`SyntaxError: Unexpected token '}'`)) }) await test('stream - objects', async t => { const {stdout} = await run('{"foo": "bar"}\n{"foo": "baz"}') t.equal(stdout, '{\n "foo": "bar"\n}\n{\n "foo": "baz"\n}\n') }) await test('stream - strings', async t => { const {stdout} = await run('"foo"\n"bar"') t.equal(stdout, 'foo\nbar\n') }) await test('flags - raw flag', async t => { const {stdout} = await run(123, `-r 'x => typeof x'`) t.equal(stdout, 'string\n') }) await test('flags - raw reads entire input', async t => { const {stdout} = await run('foo\bbar', `-r`) t.equal(stdout, 'foo\bbar\n') }) await test('flags - slurp flag', async t => { const {stdout} = await run('{"foo": "bar"}\n{"foo": "baz"}', `-s '.[1].foo'`) t.equal(stdout, 'baz\n') }) await test('flags - slurp raw', async t => { const {stdout} = await run('hello,\nworld!', `-rs '.join(" ")'`) t.equal(stdout, 'hello, world!\n') }) await test('cli - first arg is file', async t => { const {stdout} = await runNoPipe(`package.json .name`) t.equal(stdout, 'fx\n') }) await test('cli - last arg is file', async t => { const {stdout} = await runNoPipe(`.name package.json`) t.equal(stdout, 'fx\n') }) await test('cli - very large arg', async t => { const {status, stderr, stdout} = await run(42, `'x => x /* dsasdfaskjdfhaskldjfhgaslkdjfhasdlkfjhasdlkfjhasdlfkjhasdflkjasdhflkjasdhflacnskdcfhalsdkfjhasldkfjhcasdlckfajhdsflbkasjdhfclnaskdjhfalskdfgjhsdflkfjhasdlfkahjsdflkasjhdflkafdggrhdfggsdfghsdghadfgsdfgsdfglhadshfglaksjdfhalskjdfhasldkfjhaldfkjhasdlfkjhasdflkjhadflkhasdlkfjhdfkhjasdlfkjhasdflkhaflkcansdfhvlkvajhfgvbalergtcqwaleifhavslbkfchasdblkfhldsfhasdfasfasdfdfdddddddadlakfjhas */'`) t.equal(status, 0, stderr) t.equal(stdout, '42\n') }) }() ================================================ FILE: preview.go ================================================ package main import ( "fmt" "regexp" "strings" "github.com/charmbracelet/bubbles/key" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) var reverseStyle = lipgloss.NewStyle().Reverse(true).Render func (m *model) handlePreviewKey(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd if msg, ok := msg.(tea.KeyMsg); ok { if m.previewSearchInput.Focused() { return m.handlePreviewSearchInput(msg) } switch { case key.Matches(msg, keyMap.Quit), key.Matches(msg, keyMap.Preview): m.showPreview = false return m, nil case key.Matches(msg, keyMap.Print): return m, m.print() case key.Matches(msg, keyMap.GotoTop): m.preview.GotoTop() return m, nil case key.Matches(msg, keyMap.GotoBottom): m.preview.GotoBottom() return m, nil case key.Matches(msg, keyMap.HalfPageUp): m.preview.HalfPageUp() return m, nil case key.Matches(msg, keyMap.HalfPageDown): m.preview.HalfPageDown() return m, nil case key.Matches(msg, keyMap.PageUp): m.preview.PageUp() return m, nil case key.Matches(msg, keyMap.PageDown): m.preview.PageDown() return m, nil case key.Matches(msg, keyMap.Search): m.previewSearchInput.CursorEnd() m.previewSearchInput.Width = m.termWidth - 2 m.previewSearchInput.Focus() return m, nil case key.Matches(msg, keyMap.SearchNext): m.selectPreviewSearchResult(m.previewSearchCursor + 1) return m, nil case key.Matches(msg, keyMap.SearchPrev): m.selectPreviewSearchResult(m.previewSearchCursor - 1) return m, nil } } m.preview, cmd = m.preview.Update(msg) return m, cmd } func (m *model) handlePreviewSearchInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) { var cmd tea.Cmd switch { case msg.Type == tea.KeyEscape: m.previewSearchInput.Blur() m.previewSearchInput.SetValue("") m.previewSearchResults = nil m.previewSearchCursor = -1 m.preview.SetContent(m.wrapString(m.previewValue)) return m, nil case msg.Type == tea.KeyEnter: m.previewSearchInput.Blur() found := m.doPreviewSearch(m.previewSearchInput.Value()) if !found { m.previewSearchResults = nil m.previewSearchCursor = -1 m.preview.SetContent(m.wrapString(m.previewValue)) } return m, nil default: m.previewSearchInput, cmd = m.previewSearchInput.Update(msg) } return m, cmd } func (m *model) doPreviewSearch(pattern string) bool { if pattern == "" { return false } code, ci := regexCase(pattern) if ci { code = "(?i)" + code } re, err := regexp.Compile(code) if err != nil { return false } content := m.previewValue matches := re.FindAllStringIndex(content, -1) m.previewSearchResults = nil if len(matches) == 0 { return false } // Precalculate visual line numbers for each match, accounting for line wrapping lines := strings.Split(content, "\n") visualLineStarts := make([]int, len(lines)) cumulative := 0 for i, line := range lines { visualLineStarts[i] = cumulative wrapped := m.wrapString(line) cumulative += strings.Count(wrapped, "\n") + 1 } for _, match := range matches { // Find original line number origLineNum := strings.Count(content[:match[0]], "\n") // Find position within the original line lastNewline := strings.LastIndex(content[:match[0]], "\n") var posInLine int if lastNewline == -1 { posInLine = match[0] } else { posInLine = match[0] - lastNewline - 1 } // Calculate visual line: start of this original line + offset within wrapped line visualLineNum := visualLineStarts[origLineNum] // Add offset for wrapping within the line if posInLine > 0 && m.termWidth > 0 && posInLine <= len(lines[origLineNum]) { linePrefix := lines[origLineNum][:posInLine] wrappedPrefix := m.wrapString(linePrefix) visualLineNum += strings.Count(wrappedPrefix, "\n") } m.previewSearchResults = append(m.previewSearchResults, visualLineNum) } // Highlight all matches with Reverse style (once) var result strings.Builder lastEnd := 0 for _, match := range matches { start, end := match[0], match[1] if start > lastEnd { result.WriteString(content[lastEnd:start]) } result.WriteString(reverseStyle(content[start:end])) lastEnd = end } if lastEnd < len(content) { result.WriteString(content[lastEnd:]) } m.preview.SetContent(m.wrapString(result.String())) // Jump to first match m.previewSearchCursor = 0 m.preview.SetYOffset(m.previewSearchResults[0]) return true } func (m *model) selectPreviewSearchResult(i int) { if len(m.previewSearchResults) == 0 { return } if i < 0 { i = len(m.previewSearchResults) - 1 } if i >= len(m.previewSearchResults) { i = 0 } m.previewSearchCursor = i // Scroll to the cached line number m.preview.SetYOffset(m.previewSearchResults[i]) } func (m *model) previewSearchStatusBar() string { if m.previewSearchInput.Focused() { return m.previewSearchInput.View() } pattern := m.previewSearchInput.Value() if pattern == "" { return "" } re, ci := regexCase(pattern) re = "/" + re + "/" if ci { re += "i" } if len(m.previewSearchResults) == 0 { return flex(m.termWidth, re, "not found") } cursor := fmt.Sprintf("found: [%v/%v]", m.previewSearchCursor+1, len(m.previewSearchResults)) return flex(m.termWidth, re, cursor) } func (m *model) wrapString(value string) string { return lipgloss.NewStyle().Width(m.termWidth).Render(value) } ================================================ FILE: scripts/build.mjs ================================================ $.verbose = true const goos = [ 'linux', 'darwin', 'windows', ] const goarch = [ 'amd64', 'arm64', ] const name = (GOOS, GOARCH) => `fx_${GOOS}_${GOARCH}` + (GOOS === 'windows' ? '.exe' : '') const resp = await fetch('https://api.github.com/repos/antonmedv/fx/releases/latest') const {tag_name: latest} = await resp.json() await $`go mod download` await Promise.all( goos.flatMap(GOOS => goarch.map(GOARCH => $`GOOS=${GOOS} GOARCH=${GOARCH} go build -o ${name(GOOS, GOARCH)}`))) await Promise.all( goos.flatMap(GOOS => goarch.map(GOARCH => $`gh release upload ${latest} ${name(GOOS, GOARCH)}`))) await Promise.all( goos.flatMap(GOOS => goarch.map(GOARCH => $`rm ${name(GOOS, GOARCH)}`))) ================================================ FILE: search.go ================================================ package main import ( "regexp" tea "github.com/charmbracelet/bubbletea" . "github.com/antonmedv/fx/internal/jsonx" ) func (m *model) doSearch(s string) tea.Cmd { if s == "" { return nil } m.searching = true m.searchID++ m.searchCancel = make(chan struct{}) id := m.searchID cancel := m.searchCancel top := m.top query := s return tea.Batch(m.spinner.Tick, func() tea.Msg { result, err := executeSearch(top, query, cancel) if err != nil { errSearch := newSearch() errSearch.err = err return searchResultMsg{id: id, query: query, search: errSearch} } if result == nil { // Search was cancelled return searchCancelledMsg{} } return searchResultMsg{id: id, query: query, search: result} }) } func (m *model) cancelSearch() { if m.searchCancel != nil { close(m.searchCancel) m.searchCancel = nil m.searching = false } } func (m *model) selectSearchResult(i int) { if len(m.search.results) == 0 { return } if i < 0 { i = len(m.search.results) - 1 } if i >= len(m.search.results) { i = 0 } m.search.cursor = i result := m.search.results[i] m.selectNode(result) m.showCursor = false } func (m *model) redoSearch() { s := m.searchInput.Value() if s == "" || len(m.search.results) == 0 { return } cursor := m.search.cursor // Perform search synchronously (no cancellation needed for redo) result, err := executeSearch(m.top, s, nil) if err != nil { m.search = newSearch() m.search.err = err return } m.search = result m.selectSearchResult(cursor) } type search struct { err error results []*Node cursor int values map[*Node][]match keys map[*Node][]match } func newSearch() *search { return &search{ results: make([]*Node, 0), values: make(map[*Node][]match), keys: make(map[*Node][]match), } } type match struct { start, end int index int } type piece struct { b string index int } // executeSearch performs the core search logic and returns the results. // It can be cancelled via the cancel channel (pass nil for non-cancellable search). func executeSearch(top *Node, s string, cancel <-chan struct{}) (*search, error) { code, ci := regexCase(s) if ci { code = "(?i)" + code } re, err := regexp.Compile(code) if err != nil { return nil, err } result := newSearch() n := top searchIndex := 0 for n != nil { // Check for cancellation if channel provided if cancel != nil { select { case <-cancel: return nil, nil // cancelled default: } } if n.Key != "" { indexes := re.FindAllStringIndex(n.Key, -1) if len(indexes) > 0 { for i, pair := range indexes { result.results = append(result.results, n) result.keys[n] = append(result.keys[n], match{start: pair[0], end: pair[1], index: searchIndex + i}) } searchIndex += len(indexes) } } indexes := re.FindAllStringIndex(n.Value, -1) if len(indexes) > 0 { for range indexes { result.results = append(result.results, n) } if n.Chunk != "" { // String can be split into chunks, so we need to map the indexes to the chunks. chunks := []string{n.Chunk} chunkNodes := []*Node{n} it := n.Next for it != nil { chunkNodes = append(chunkNodes, it) chunks = append(chunks, it.Chunk) if it == n.ChunkEnd { break } it = it.Next } chunkMatches := splitIndexesToChunks(chunks, indexes, searchIndex) for i, matches := range chunkMatches { result.values[chunkNodes[i]] = matches } } else { for i, pair := range indexes { result.values[n] = append(result.values[n], match{start: pair[0], end: pair[1], index: searchIndex + i}) } } searchIndex += len(indexes) } if n.IsCollapsed() { n = n.Collapsed } else { n = n.Next } } return result, nil } func splitByIndexes(s string, indexes []match) []piece { out := make([]piece, 0, 1) pos := 0 for _, pair := range indexes { out = append(out, piece{safeSlice(s, pos, pair.start), -1}) out = append(out, piece{safeSlice(s, pair.start, pair.end), pair.index}) pos = pair.end } out = append(out, piece{safeSlice(s, pos, len(s)), -1}) return out } func splitIndexesToChunks(chunks []string, indexes [][]int, searchIndex int) (chunkIndexes [][]match) { chunkIndexes = make([][]match, len(chunks)) for index, idx := range indexes { position := 0 for i, chunk := range chunks { // If start index lies in this chunk if idx[0] < position+len(chunk) { // Calculate local start and end for this chunk localStart := idx[0] - position localEnd := idx[1] - position // If the end index also lies in this chunk if idx[1] <= position+len(chunk) { chunkIndexes[i] = append(chunkIndexes[i], match{start: localStart, end: localEnd, index: searchIndex + index}) break } else { // If the end index is outside this chunk, split the index chunkIndexes[i] = append(chunkIndexes[i], match{start: localStart, end: len(chunk), index: searchIndex + index}) // Adjust the starting index for the next chunk idx[0] = position + len(chunk) } } position += len(chunk) } } return } ================================================ FILE: search_test.go ================================================ package main import ( "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" . "github.com/antonmedv/fx/internal/jsonx" ) // doSearch is a test helper that performs a synchronous search using executeSearch. func doSearch(m *model, s string) { if s == "" { return } result, err := executeSearch(m.top, s, nil) if err != nil { m.search = newSearch() m.search.err = err return } m.search = result m.selectSearchResult(0) } func TestBasicSearch(t *testing.T) { jsonData := `{ "name": "John Doe", "age": 30, "email": "john@example.com", "active": true, "skills": ["JavaScript", "Go", "Python"] }` head, err := Parse([]byte(jsonData)) require.NoError(t, err) m := &model{ top: head, head: head, search: newSearch(), } testCases := []struct { searchTerm string expectedResults int description string }{ {"John", 1, "Simple string search"}, {"30", 1, "Number search"}, {"example.com", 1, "Domain search"}, {"JavaScript", 1, "Array element search"}, {"active", 1, "Boolean key search"}, {"nonexistent", 0, "No match search"}, {"", 0, "Empty search"}, } for _, tc := range testCases { t.Run(tc.description, func(t *testing.T) { doSearch(m, tc.searchTerm) if tc.expectedResults == 0 { assert.Equal(t, 0, len(m.search.results), "Should find no results for: %s", tc.searchTerm) } else { assert.Greater(t, len(m.search.results), 0, "Should find results for: %s", tc.searchTerm) } assert.Nil(t, m.search.err, "Search should not error for: %s", tc.searchTerm) }) } } func TestRegexSearch(t *testing.T) { jsonData := `{ "users": [ {"id": "USER-001", "email": "alice@company.com", "score": 95.5}, {"id": "USER-002", "email": "bob@company.com", "score": 87.2}, {"id": "ADMIN-001", "email": "admin@company.com", "score": 100.0} ], "metadata": { "version": "v1.2.3", "timestamp": "2024-01-15T10:30:00Z" } }` head, err := Parse([]byte(jsonData)) require.NoError(t, err) m := &model{ top: head, head: head, search: newSearch(), } testCases := []struct { pattern string shouldMatch bool description string }{ // Pattern matching {"USER-\\d+", true, "User ID pattern"}, {"\\d+\\.\\d+", true, "Decimal number pattern"}, {"[a-z]+@[a-z]+\\.com", true, "Email pattern"}, {"v\\d+\\.\\d+\\.\\d+", true, "Version pattern"}, {"\\d{4}-\\d{2}-\\d{2}", true, "Date pattern"}, // Anchored searches {"^\"id\"", true, "Key at start of line"}, {"com\"$", true, "Value at end of line"}, // Character classes {"[A-Z]{4,}", true, "Uppercase letters"}, {"[0-9]{3}", true, "Three digits"}, // Quantifiers {"o{2}", true, "Double 'o'"}, {"a+", true, "One or more 'a'"}, {"z*", true, "Zero or more 'z'"}, // Invalid patterns should error {"[", false, "Invalid bracket"}, {"(", false, "Unclosed parenthesis"}, {"*", false, "Invalid quantifier"}, } for _, tc := range testCases { t.Run(tc.description, func(t *testing.T) { doSearch(m, tc.pattern) if tc.shouldMatch { assert.Nil(t, m.search.err, "Pattern should be valid: %s", tc.pattern) } else { assert.NotNil(t, m.search.err, "Pattern should be invalid: %s", tc.pattern) } }) } } func TestCaseInsensitiveSearch(t *testing.T) { jsonData := `{ "Company": "ACME Corporation", "employees": [ {"Name": "Alice Johnson", "Department": "Engineering"}, {"name": "bob smith", "department": "marketing"}, {"NAME": "CHARLIE BROWN", "DEPARTMENT": "SALES"} ] }` head, err := Parse([]byte(jsonData)) require.NoError(t, err) m := &model{ top: head, head: head, search: newSearch(), } testCases := []struct { searchTerm string description string }{ {"alice", "Lowercase search for mixed case"}, {"ALICE", "Uppercase search for mixed case"}, {"Alice", "Proper case search"}, {"aLiCe", "Mixed case search"}, {"company", "Lowercase search for uppercase"}, {"ENGINEERING", "Uppercase search for proper case"}, {"marketing", "Exact case match"}, } for _, tc := range testCases { t.Run(tc.description, func(t *testing.T) { doSearch(m, tc.searchTerm) // Case insensitive search should find matches regardless of case assert.Greater(t, len(m.search.results), 0, "Should find case-insensitive match for: %s", tc.searchTerm) assert.Nil(t, m.search.err, "Search should not error") }) } } func TestSearchInDifferentNodeTypes(t *testing.T) { jsonData := `{ "string_field": "hello world", "number_field": 42, "boolean_field": true, "null_field": null, "array_field": [1, "two", 3.14, false], "object_field": { "nested_string": "nested value", "nested_number": 99 } }` head, err := Parse([]byte(jsonData)) require.NoError(t, err) m := &model{ top: head, head: head, search: newSearch(), } testCases := []struct { searchTerm string nodeType string description string }{ {"hello", "string", "Search in string values"}, {"42", "number", "Search in number values"}, {"true", "boolean", "Search in boolean values"}, {"null", "null", "Search in null values"}, {"two", "array element", "Search in array elements"}, {"nested", "object property", "Search in nested objects"}, {"string_field", "key", "Search in JSON keys"}, {"3.14", "float", "Search in floating point numbers"}, } for _, tc := range testCases { t.Run(tc.description, func(t *testing.T) { doSearch(m, tc.searchTerm) assert.Greater(t, len(m.search.results), 0, "Should find %s in %s", tc.searchTerm, tc.nodeType) assert.Nil(t, m.search.err, "Search should not error") }) } } func TestSearchResultDetails(t *testing.T) { jsonData := `{ "message": "The quick brown fox jumps over the lazy dog", "words": ["fox", "dog", "fox"] }` head, err := Parse([]byte(jsonData)) require.NoError(t, err) m := &model{ top: head, head: head, search: newSearch(), } // Test multiple matches in same value doSearch(m, "fox") assert.Greater(t, len(m.search.results), 0, "Should find fox matches") assert.Nil(t, m.search.err, "Search should not error") // Verify that we have both key matches and value matches hasKeyMatches := len(m.search.keys) > 0 hasValueMatches := len(m.search.values) > 0 assert.True(t, hasKeyMatches || hasValueMatches, "Should have either key or value matches") // Test that search cursor is initialized if len(m.search.results) > 0 { assert.GreaterOrEqual(t, m.search.cursor, 0, "Search cursor should be valid") assert.Less(t, m.search.cursor, len(m.search.results), "Search cursor should be within bounds") } } func TestSearchNavigation(t *testing.T) { jsonData := `{ "items": [ {"name": "apple", "color": "red"}, {"name": "banana", "color": "yellow"}, {"name": "apple", "color": "green"} ] }` head, err := Parse([]byte(jsonData)) require.NoError(t, err) m := &model{ top: head, head: head, search: newSearch(), } // Search for term with multiple matches doSearch(m, "apple") require.Greater(t, len(m.search.results), 1, "Should find multiple apple matches") initialCursor := m.search.cursor // Test forward navigation m.selectSearchResult(m.search.cursor + 1) assert.NotEqual(t, initialCursor, m.search.cursor, "Cursor should move forward") assert.GreaterOrEqual(t, m.search.cursor, 0, "Cursor should be valid") assert.Less(t, m.search.cursor, len(m.search.results), "Cursor should be within bounds") // Test wrap-around (next from last should go to first) lastIndex := len(m.search.results) - 1 m.selectSearchResult(lastIndex + 1) assert.Equal(t, 0, m.search.cursor, "Should wrap to first result") // Test backward wrap-around (previous from first should go to last) m.selectSearchResult(-1) assert.Equal(t, lastIndex, m.search.cursor, "Should wrap to last result") } func TestSpecialCharacterSearch(t *testing.T) { jsonData := `{ "symbols": "!@#$%^&*()_+-={}[]|\\:;\"'<>?,./'", "escaped": "Line 1\nLine 2\tTabbed\r\nWindows line", "unicode": "café, naïve, résumé, 中文, 🚀", "quotes": "He said \"Hello world!\"", "backslash": "C:\\Users\\Name\\file.txt" }` head, err := Parse([]byte(jsonData)) require.NoError(t, err) m := &model{ top: head, head: head, search: newSearch(), } testCases := []struct { searchTerm string description string }{ {"@", "At symbol"}, {"\\$", "Dollar sign (escaped)"}, {"\\*", "Asterisk (escaped)"}, {"\\[", "Square bracket (escaped)"}, {"\\\\n", "Newline escape sequence"}, {"\\\\t", "Tab escape sequence"}, {"café", "Unicode characters"}, {"中文", "Chinese characters"}, {"🚀", "Emoji"}, {"\\\"", "Escaped quotes"}, {"C:", "Drive letter"}, } for _, tc := range testCases { t.Run(tc.description, func(t *testing.T) { doSearch(m, tc.searchTerm) // Should either find matches or have valid regex (no panic) assert.Nil(t, m.search.err, "Should handle special characters without error: %s", tc.searchTerm) }) } } func TestEmptyAndEdgeCases(t *testing.T) { testCases := []struct { jsonData string searchTerm string description string }{ {`{}`, "anything", "Empty object"}, {`[]`, "anything", "Empty array"}, {`""`, "empty", "Empty string value"}, {`{"": "empty key"}`, "", "Empty key search"}, {`{"key": ""}`, "key", "Search in empty value"}, {`null`, "null", "Null document"}, {`false`, "false", "Boolean document"}, {`0`, "0", "Zero value"}, {`{"very_long_key_name_that_exceeds_normal_length": "value"}`, "very_long", "Long key names"}, } for _, tc := range testCases { t.Run(tc.description, func(t *testing.T) { head, err := Parse([]byte(tc.jsonData)) require.NoError(t, err) m := &model{ top: head, head: head, search: newSearch(), } doSearch(m, tc.searchTerm) // Should not panic or error on edge cases assert.Nil(t, m.search.err, "Should handle edge case without error") assert.NotNil(t, m.search.results, "Results should not be nil") }) } } func TestLargeJSONSearch(t *testing.T) { // Build a larger JSON structure jsonBuilder := `{"users": [` for i := 0; i < 100; i++ { if i > 0 { jsonBuilder += "," } jsonBuilder += `{"id": ` + string(rune(i)) + `, "name": "User` + string(rune(i)) + `", "active": ` if i%2 == 0 { jsonBuilder += "true" } else { jsonBuilder += "false" } jsonBuilder += `}` } jsonBuilder += `]}` // Use simpler approach for test jsonData := `{ "users": [ {"id": 1, "name": "User1", "active": true}, {"id": 2, "name": "User2", "active": false}, {"id": 3, "name": "User3", "active": true} ], "repeated_data": ["test", "test", "test", "test", "test"] }` head, err := Parse([]byte(jsonData)) require.NoError(t, err) m := &model{ top: head, head: head, search: newSearch(), } // Test that search completes in reasonable time doSearch(m, "User") assert.Greater(t, len(m.search.results), 0, "Should find users in large JSON") assert.Nil(t, m.search.err, "Should not error on large JSON") // Test repeated terms doSearch(m, "test") assert.Greater(t, len(m.search.results), 3, "Should find multiple instances of repeated term") } func TestSearchInWrappedStrings(t *testing.T) { // JSON with a long string that will be wrapped jsonData := `{ "description": "This is a very long string that should be wrapped across multiple lines when the terminal width is narrow enough to trigger wrapping behavior" }` head, err := Parse([]byte(jsonData)) require.NoError(t, err) // Apply wrapping with a narrow terminal width to force chunks Wrap(head, 40) m := &model{ top: head, head: head, search: newSearch(), wrap: true, termWidth: 40, } testCases := []struct { searchTerm string expectMatch bool description string }{ {"very long string", true, "Multi-word search in wrapped text"}, {"wrapped across", true, "Search spanning potential chunk boundary"}, {"terminal width", true, "Search near end of wrapped text"}, {"This is a", true, "Search at beginning of wrapped text"}, {"behavior", true, "Search at very end of wrapped text"}, {"nonexistent phrase", false, "No match in wrapped text"}, } for _, tc := range testCases { t.Run(tc.description, func(t *testing.T) { doSearch(m, tc.searchTerm) if tc.expectMatch { assert.Greater(t, len(m.search.results), 0, "Should find '%s' in wrapped text", tc.searchTerm) } else { assert.Equal(t, 0, len(m.search.results), "Should not find '%s' in wrapped text", tc.searchTerm) } assert.Nil(t, m.search.err, "Search should not error") }) } } func TestSearchChunkBoundaryMatches(t *testing.T) { // Create a string where we know approximately where chunk boundaries will be // With termWidth=30 and some indentation, each chunk will be roughly 25-28 chars jsonData := `{"text": "AAAAA BBBBB CCCCC DDDDD EEEEE FFFFF GGGGG HHHHH IIIII JJJJJ"}` head, err := Parse([]byte(jsonData)) require.NoError(t, err) // Apply wrapping with narrow width Wrap(head, 30) m := &model{ top: head, head: head, search: newSearch(), wrap: true, termWidth: 30, } // Verify chunks were created textNode := head.Next // Skip the opening brace to get to the "text" key node require.NotNil(t, textNode, "Should have text node") require.NotEmpty(t, textNode.Chunk, "Text node should have chunks (Chunk field set)") require.NotNil(t, textNode.ChunkEnd, "Text node should have ChunkEnd set") testCases := []struct { pattern string description string }{ {"AAAAA", "Match in first chunk"}, {"JJJJJ", "Match in last chunk"}, {"[A-Z]{5}", "Regex matching all groups"}, {"BBBBB CCCCC", "Match potentially spanning chunks"}, {"FFFFF GGGGG HHHHH", "Match spanning multiple chunks"}, } for _, tc := range testCases { t.Run(tc.description, func(t *testing.T) { doSearch(m, tc.pattern) assert.Greater(t, len(m.search.results), 0, "Should find matches for pattern: %s", tc.pattern) assert.Nil(t, m.search.err, "Search should not error") // Verify matches are recorded in the values map assert.Greater(t, len(m.search.values), 0, "Should have value matches recorded") }) } } func TestSearchMultipleMatchesInChunks(t *testing.T) { // String with repeated pattern that will span multiple chunks jsonData := `{"data": "test123 test456 test789 test000 test111 test222 test333 test444"}` head, err := Parse([]byte(jsonData)) require.NoError(t, err) Wrap(head, 35) m := &model{ top: head, head: head, search: newSearch(), wrap: true, termWidth: 35, } // Search for pattern that should match multiple times doSearch(m, "test\\d{3}") assert.Greater(t, len(m.search.results), 0, "Should find test patterns") assert.Nil(t, m.search.err, "Search should not error") // Check that matches are distributed across chunk nodes totalMatches := 0 for _, matches := range m.search.values { totalMatches += len(matches) } assert.Greater(t, totalMatches, 0, "Should have matches across chunks") } func TestSearchWrappedVsUnwrapped(t *testing.T) { jsonData := `{"message": "The quick brown fox jumps over the lazy dog multiple times today"}` head, err := Parse([]byte(jsonData)) require.NoError(t, err) // First test without wrapping m := &model{ top: head, head: head, search: newSearch(), wrap: false, termWidth: 80, } doSearch(m, "fox") unwrappedResults := len(m.search.results) assert.Greater(t, unwrappedResults, 0, "Should find 'fox' without wrapping") // Now apply wrapping and search again Wrap(head, 30) m.wrap = true m.termWidth = 30 doSearch(m, "fox") wrappedResults := len(m.search.results) assert.Equal(t, unwrappedResults, wrappedResults, "Should find same number of results with or without wrapping") } func TestSearchChunkIndexMapping(t *testing.T) { // Test that search result indices are correctly mapped to chunks jsonData := `{"value": "START middle portion of text END"}` head, err := Parse([]byte(jsonData)) require.NoError(t, err) // Use very narrow width to force multiple chunks Wrap(head, 20) m := &model{ top: head, head: head, search: newSearch(), wrap: true, termWidth: 20, } // Search for terms at different positions testCases := []struct { term string description string }{ {"START", "Beginning of string"}, {"middle", "Middle of string"}, {"END", "End of string"}, {"portion", "Another middle term"}, } for _, tc := range testCases { t.Run(tc.description, func(t *testing.T) { doSearch(m, tc.term) assert.Greater(t, len(m.search.results), 0, "Should find '%s'", tc.term) assert.Nil(t, m.search.err) // Verify that matches are recorded in the values map assert.Greater(t, len(m.search.values), 0, "Should have value matches recorded for '%s'", tc.term) }) } } func TestSearchEmptyAndShortStringsWithWrap(t *testing.T) { testCases := []struct { jsonData string searchTerm string expectMatch bool description string }{ {`{"a": ""}`, "anything", false, "Empty string value"}, {`{"a": "x"}`, "x", true, "Single char string"}, {`{"a": "ab"}`, "ab", true, "Two char string"}, {`{"a": "short"}`, "short", true, "Short string (no wrap needed)"}, } for _, tc := range testCases { t.Run(tc.description, func(t *testing.T) { head, err := Parse([]byte(tc.jsonData)) require.NoError(t, err) // Apply wrapping even for short strings Wrap(head, 30) m := &model{ top: head, head: head, search: newSearch(), wrap: true, termWidth: 30, } doSearch(m, tc.searchTerm) if tc.expectMatch { assert.Greater(t, len(m.search.results), 0, "Should find '%s'", tc.searchTerm) } else { assert.Equal(t, 0, len(m.search.results), "Should not find '%s'", tc.searchTerm) } assert.Nil(t, m.search.err) }) } } func TestSearchRegexAcrossChunks(t *testing.T) { // Test regex patterns that might match across chunk boundaries jsonData := `{"content": "email: user@example.com phone: 123-456-7890 date: 2024-01-15"}` head, err := Parse([]byte(jsonData)) require.NoError(t, err) Wrap(head, 25) m := &model{ top: head, head: head, search: newSearch(), wrap: true, termWidth: 25, } testCases := []struct { pattern string expectMinNum int description string }{ {`\w+@\w+\.\w+`, 1, "Email pattern"}, {`\d{3}-\d{3}-\d{4}`, 1, "Phone pattern"}, {`\d{4}-\d{2}-\d{2}`, 1, "Date pattern"}, {`\d+`, 6, "All number sequences (123,456,7890,2024,01,15)"}, {`[a-z]+:`, 3, "Labels (email:, phone:, date:)"}, } for _, tc := range testCases { t.Run(tc.description, func(t *testing.T) { doSearch(m, tc.pattern) assert.GreaterOrEqual(t, len(m.search.results), tc.expectMinNum, "Pattern '%s' should find at least %d matches", tc.pattern, tc.expectMinNum) assert.Nil(t, m.search.err) }) } } ================================================ FILE: snap/snapcraft.yaml ================================================ name: fx version: 39.2.0 summary: Terminal JSON viewer description: Terminal JSON viewer base: core20 grade: stable confinement: strict architectures: - build-on: armhf - build-on: amd64 - build-on: arm64 plugs: dot-fxrc-js: interface: personal-files read: - $HOME/.fxrc.js apps: fx: command: bin/fx plugs: [ dot-fxrc-js, home, network ] parts: fx: plugin: go source: https://github.com/antonmedv/fx.git source-type: git stage-snaps: - node/18/stable ================================================ FILE: testdata/TestCollapseRecursive.golden ================================================ [?25l[?2004h { "title": "Lorem ipsum", "text": "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusm od tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam , quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo cons equat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum d olore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident , sunt in culpa qui officia deserunt mollit anim id est laborum.", "tags": […], "year": 3000, "funny": true, "author": {"name":"John Doe",…} } ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ 1%  ================================================ FILE: testdata/TestCollapseRecursiveWithSizes.golden ================================================ [?25l[?2004h { (6 keys) "title": "Lorem ipsum", "text": "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusm od tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam , quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo cons equat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum d olore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident , sunt in culpa qui officia deserunt mollit anim id est laborum.", "tags": […], (3 items) "year": 3000, "funny": true, "author": {"name":"John Doe",…} (2 keys) } ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ 1%  ================================================ FILE: testdata/TestGotoLine.golden ================================================ [?25l[?2004h  1 {  2 "title": "Lorem ipsum",  3 "text": "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do e iusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad mini m veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate vel it esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cu pidatat non proident, sunt in culpa qui officia deserunt mollit anim id es t laborum.",  4 "tags": [  5 "lorem",  6 "ipsum",  7 null  8 ],  9 "year": 3000, 10 "funny": true, 11 "author": { 12 "name": "John Doe", 13 "email": "john@doe.com" 14 } 15 } ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ .tags[0] 33%  ================================================ FILE: testdata/TestGotoLineCollapsed.golden ================================================ [?25l[?2004h  1 {  2 "title": "Lorem ipsum",  3 "text": "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do e iusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad mini m veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate vel it esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cu pidatat non proident, sunt in culpa qui officia deserunt mollit anim id es t laborum.",  4 "tags": [  5 "lorem",  6 "ipsum",  7 null  8 ],  9 "year": 3000, 10 "funny": true, 11 "author": {"name":"John Doe",…} 15 } ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ .tags[0] 33%  ================================================ FILE: testdata/TestGotoLineInputGreaterThanTotalLines.golden ================================================ [?25l[?2004h  1 {  2 "title": "Lorem ipsum",  3 "text": "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do e iusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad mini m veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate vel it esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cu pidatat non proident, sunt in culpa qui officia deserunt mollit anim id es t laborum.",  4 "tags": [  5 "lorem",  6 "ipsum",  7 null  8 ],  9 "year": 3000, 10 "funny": true, 11 "author": { 12 "name": "John Doe", 13 "email": "john@doe.com" 14 } 15 } ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ 100%  ================================================ FILE: testdata/TestGotoLineInputInvalid.golden ================================================ [?25l[?2004h  1 {  2 "title": "Lorem ipsum",  3 "text": "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do e iusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad mini m veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate vel it esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cu pidatat non proident, sunt in culpa qui officia deserunt mollit anim id es t laborum.",  4 "tags": […],  9 "year": 3000, 10 "funny": true, 11 "author": {"name":"John Doe",…} 15 } ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ .text 20%  ================================================ FILE: testdata/TestGotoLineInputLessThanOne.golden ================================================ [?25l[?2004h  1 {  2 "title": "Lorem ipsum",  3 "text": "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do e iusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad mini m veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate vel it esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cu pidatat non proident, sunt in culpa qui officia deserunt mollit anim id es t laborum.",  4 "tags": [  5 "lorem",  6 "ipsum",  7 null  8 ],  9 "year": 3000, 10 "funny": true, 11 "author": { 12 "name": "John Doe", 13 "email": "john@doe.com" 14 } 15 } ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ 1%  ================================================ FILE: testdata/TestGotoLineKeepsHistory.golden ================================================ [?25l[?2004h  1 {  2 "title": "Lorem ipsum",  3 "text": "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do e iusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad mini m veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate vel it esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cu pidatat non proident, sunt in culpa qui officia deserunt mollit anim id es t laborum.",  4 "tags": [  5 "lorem",  6 "ipsum",  7 null  8 ],  9 "year": 3000, 10 "funny": true, 11 "author": { 12 "name": "John Doe", 13 "email": "john@doe.com" 14 } 15 } ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ .tags 26%  ================================================ FILE: testdata/TestNavigation.golden ================================================ [?25l[?2004h { "title": "Lorem ipsum", "text": "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusm od tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam , quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo cons equat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum d olore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident , sunt in culpa qui officia deserunt mollit anim id est laborum.", "tags": [ "lorem", "ipsum", null ], "year": 3000, "funny": true, "author": { "name": "John Doe", "email": "john@doe.com" } } ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ .text 20%  ================================================ FILE: testdata/TestOutput.golden ================================================ [?25l[?2004h { "title": "Lorem ipsum", "text": "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusm od tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam , quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo cons equat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum d olore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident , sunt in culpa qui officia deserunt mollit anim id est laborum.", "tags": [ "lorem", "ipsum", null ], "year": 3000, "funny": true, "author": { "name": "John Doe", "email": "john@doe.com" } } ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ 1%  ================================================ FILE: testdata/blog.json ================================================ { "title": "Lorem ipsum", "body": "# Lorem Ipsum Dolor Sit Amet\n\n## Consectetur Adipiscing Elit\n\nLorem ipsum dolor sit amet, consectetur adipiscing elit. *Pellentesque vitae* velit ex. **Mauris** euismod pellentesque tellus sit amet mollis.\n\n- Lorem ipsum dolor sit amet, consectetur adipiscing elit.\n- Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.\n- Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.\n\n### Duis aute irure dolor in reprehenderit\n\n1. In voluptate velit esse cillum dolore eu fugiat nulla pariatur.\n2. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\n#### Laboris Nisi\n\nUt aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.\n\n##### Exercitationem Ullam\n\n```python\n# Example Python code\ndef hello_world():\n print(\"Hello, world!\")\n```", "tags": [ "lorem", "ipsum" ], "author": { "name": "John Doe", "email": "john@doe.com" } } ================================================ FILE: testdata/example.json ================================================ { "title": "Lorem ipsum", "text": "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", "tags": [ "lorem", "ipsum", null ], "year": 3000, "funny": true, "author": { "name": "John Doe", "email": "john@doe.com" } } ================================================ FILE: utils.go ================================================ package main import ( "bytes" "errors" "io" "io/fs" "os" "path" "regexp" "strconv" "strings" "github.com/goccy/go-yaml" "github.com/antonmedv/fx/internal/jsonpath" "github.com/antonmedv/fx/internal/jsonx" ) func lookup(names []string, defaultEditor string) string { for _, name := range names { env, ok := os.LookupEnv(name) if ok && env != "" { return env } } return defaultEditor } func open(filePath string, flagYaml, flagToml *bool) *os.File { f, err := os.Open(filePath) if err != nil { var pathError *fs.PathError if errors.As(err, &pathError) { println(err.Error()) os.Exit(1) } else { panic(err) } } fileName := path.Base(filePath) hasYamlExt, _ := regexp.MatchString(`(?i)\.ya?ml$`, fileName) hasTomlExt, _ := regexp.MatchString(`(?i)\.toml$`, fileName) if !*flagYaml && hasYamlExt { *flagYaml = true } if !*flagToml && hasTomlExt { *flagToml = true } return f } func regexCase(code string) (string, bool) { if strings.HasSuffix(code, "/i") { return code[:len(code)-2], true } else if strings.HasSuffix(code, "/") { return code[:len(code)-1], false } else { return code, true } } func flex(width int, a, b string) string { return a + strings.Repeat(" ", max(1, width-len(a)-len(b))) + b } func safeSlice(s string, start, end int) string { length := len(s) if start > length { start = length } if end > length { end = length } if start < 0 { start = 0 } if end < 0 { end = 0 } if start > end { start = end } return s[start:end] } func parseYAML(b []byte) ([]byte, error) { var out []byte decoder := yaml.NewDecoder( bytes.NewReader(b), yaml.UseOrderedMap(), ) for { var v any if err := decoder.Decode(&v); err != nil { if err == io.EOF { break } return nil, err } j, err := yaml.MarshalWithOptions(v, yaml.JSON()) if err != nil { return nil, err } out = append(out, j...) } return out, nil } func isRefNode(n *jsonx.Node) (string, bool) { if n.Kind == jsonx.String && len(n.Key) == 6 && string(n.Key) == `"$ref"` { value, err := strconv.Unquote(n.Value) if err == nil { _, ok := jsonpath.ParseSchemaRef(value) if ok { return value, true } } } return "", false } ================================================ FILE: version.go ================================================ package main const version = "39.2.0" ================================================ FILE: view.go ================================================ package main import ( "bytes" "fmt" "strconv" "strings" "github.com/antonmedv/fx/internal/ident" . "github.com/antonmedv/fx/internal/jsonx" "github.com/antonmedv/fx/internal/theme" "github.com/antonmedv/fx/internal/utils" ) func (m *model) View() string { if m.suspending { return "" } if m.showHelp { return m.help.View() } if m.showPreview { searchBar := m.previewSearchStatusBar() if searchBar != "" { return m.preview.View() + "\n" + searchBar } statusBar := flex(m.termWidth, m.cursorPath(), m.fileName) return m.preview.View() + "\n" + theme.CurrentTheme.StatusBar(statusBar) } var screen []byte printedLines := 0 n := m.head var cursorLineNumber int for lineNumber := 0; lineNumber < m.viewHeight(); lineNumber++ { if n == nil { break } if m.showLineNumbers { lineNumbersWidth := len(strconv.Itoa(m.totalLines)) if n.LineNumber == 0 { screen = append(screen, bytes.Repeat([]byte{' '}, lineNumbersWidth)...) } else { lineNumStr := fmt.Sprintf("%*d", lineNumbersWidth, n.LineNumber) screen = append(screen, theme.CurrentTheme.LineNumber(lineNumStr)...) } screen = append(screen, ' ', ' ') } for i := 0; i < int(n.Depth); i++ { screen = append(screen, ident.IdentBytes...) } isSelected := m.cursor == lineNumber if isSelected { if n.LineNumber == 0 { cursorLineNumber = n.Parent.LineNumber } else { cursorLineNumber = n.LineNumber } } if !m.showCursor { isSelected = false // don't highlight the cursor while iterating search results } isRef := false isRefSelected := false if n.Key != "" { screen = append(screen, m.prettyKey(n, isSelected)...) screen = append(screen, theme.Colon...) _, isRef = isRefNode(n) isRefSelected = isRef && isSelected isSelected = false // don't highlight the key's value } screen = append(screen, m.prettyPrint(n, isSelected, isRef)...) if n.IsCollapsed() { if n.Kind == Object { if n.Collapsed.Key != "" { screen = append(screen, theme.CurrentTheme.Preview(n.Collapsed.Key)...) screen = append(screen, theme.ColonPreview...) if len(n.Collapsed.Value) > 0 && len(n.Collapsed.Value) < 42 && n.Collapsed.Kind != Object && n.Collapsed.Kind != Array { screen = append(screen, theme.CurrentTheme.Preview(n.Collapsed.Value)...) if n.Size > 1 { screen = append(screen, theme.CommaPreview...) screen = append(screen, theme.Dot3...) } } else { screen = append(screen, theme.Dot3...) } } screen = append(screen, theme.CloseCurlyBracket...) } else if n.Kind == Array { screen = append(screen, theme.Dot3...) screen = append(screen, theme.CloseSquareBracket...) } if n.End != nil && n.End.Comma { screen = append(screen, theme.Comma...) } } if n.Comma { screen = append(screen, theme.Comma...) } if m.showSizes && n.Size > 0 { var w string if n.Size == 1 { if n.Kind == Array { w = "item" } else if n.Kind == Object { w = "key" } } else { if n.Kind == Array { w = "items" } else if n.Kind == Object { w = "keys" } } screen = append(screen, theme.CurrentTheme.Size(fmt.Sprintf(" (%d %s)", n.Size, w))...) } if isRefSelected { screen = append(screen, theme.CurrentTheme.Preview(" ctrl+g goto")...) } screen = append(screen, '\n') printedLines++ n = n.Next } for i := printedLines; i < m.viewHeight(); i++ { if m.eof { screen = append(screen, theme.Empty...) } screen = append(screen, '\n') } if m.gotoSymbolInput.Focused() && m.fuzzyMatch != nil { var matchedStr []byte str := m.fuzzyMatch.Str for i := 0; i < len(str); i++ { if utils.Contains(i, m.fuzzyMatch.Pos) { matchedStr = append(matchedStr, theme.CurrentTheme.Search(string(str[i]))...) } else { matchedStr = append(matchedStr, theme.CurrentTheme.StatusBar(string(str[i]))...) } } repeatCount := m.termWidth - len(str) if repeatCount > 0 { matchedStr = append(matchedStr, theme.CurrentTheme.StatusBar(strings.Repeat(" ", repeatCount))...) } screen = append(screen, matchedStr...) } else { statusBarWidth := m.termWidth var indicator string if m.eof { percent := int(float64(cursorLineNumber) / float64(m.totalLines) * 100) if cursorLineNumber == 1 { percent = min(1, percent) } indicator = fmt.Sprintf("%d%%", percent) } else { indicator = fmt.Sprintf(" %s", m.spinner.View()) statusBarWidth += 2 // adjust for spinner } info := fmt.Sprintf("%s %s", indicator, m.fileName) statusBar := flex(statusBarWidth, m.cursorPath(), info) screen = append(screen, theme.CurrentTheme.StatusBar(statusBar)...) } if m.yank { screen = append(screen, '\n') screen = append(screen, []byte("(y)value (p)path (k)key (b)key+value")...) } else if m.showShowSelector { screen = append(screen, '\n') screen = append(screen, []byte("(s)sizes (l)line numbers")...) } else if m.gotoSymbolInput.Focused() { screen = append(screen, '\n') screen = append(screen, m.gotoSymbolInput.View()...) } else if m.commandInput.Focused() { screen = append(screen, '\n') screen = append(screen, m.commandInput.View()...) } else if m.searchInput.Focused() { screen = append(screen, '\n') screen = append(screen, m.searchInput.View()...) } else if m.searchInput.Value() != "" { screen = append(screen, '\n') re, ci := regexCase(m.searchInput.Value()) re = "/" + re + "/" if ci { re += "i" } if m.searching { status := fmt.Sprintf("%s searching...", m.spinner.View()) screen = append(screen, flex(m.termWidth, re, status)...) } else if m.search.err != nil { screen = append(screen, flex(m.termWidth, re, m.search.err.Error())...) } else if len(m.search.results) == 0 { screen = append(screen, flex(m.termWidth, re, "not found")...) } else { cursor := fmt.Sprintf("found: [%v/%v]", m.search.cursor+1, len(m.search.results)) screen = append(screen, flex(m.termWidth, re, cursor)...) } } return string(screen) } func (m *model) centerLine(n *Node) { middle := m.visibleLines() / 2 for range middle { m.up() } m.selectNodeInView(n) } ================================================ FILE: vim.go ================================================ package main import ( "strconv" tea "github.com/charmbracelet/bubbletea" . "github.com/antonmedv/fx/internal/jsonx" ) func (m *model) runCommand(s string) (tea.Model, tea.Cmd) { num, err := strconv.Atoi(s) if err == nil { gotoLine(m, num) return m, nil } else if s == "q" { return m, tea.Quit } return m, nil } func gotoLine(m *model, num int) { m.selectNode(findNode(m, num)) m.commandInput.SetValue("") m.recordHistory() } func findNode(m *model, line int) *Node { if line >= m.totalLines { return m.top.Bottom() } if line <= 1 { return m.top } node := m.top for { if node.ChunkEnd != nil { node = node.ChunkEnd.Next } else if node.Collapsed != nil { node = node.Collapsed } else { node = node.Next } if node == nil { return nil } if node.LineNumber == line { return node } } } ================================================ FILE: vim_test.go ================================================ package main import ( "testing" "time" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/charmbracelet/x/exp/teatest" "github.com/muesli/termenv" ) func init() { lipgloss.SetColorProfile(termenv.ANSI) } func TestGotoLine(t *testing.T) { tm := prepare(t, options{showLineNumbers: true}) tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(":")}) tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("5")}) tm.Send(tea.KeyMsg{Type: tea.KeyEnter}) teatest.RequireEqualOutput(t, read(t, tm)) tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("q")}) tm.WaitFinished(t, teatest.WithFinalTimeout(time.Second)) } func TestGotoLineCollapsed(t *testing.T) { tm := prepare(t, options{showLineNumbers: true}) tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("E")}) tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(":")}) tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("5")}) tm.Send(tea.KeyMsg{Type: tea.KeyEnter}) teatest.RequireEqualOutput(t, read(t, tm)) tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("q")}) tm.WaitFinished(t, teatest.WithFinalTimeout(time.Second)) } func TestGotoLineInputInvalid(t *testing.T) { tm := prepare(t, options{showLineNumbers: true}) tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("E")}) tm.Send(tea.KeyMsg{Type: tea.KeyDown}) tm.Send(tea.KeyMsg{Type: tea.KeyDown}) tm.Send(tea.KeyMsg{Type: tea.KeyDown}) tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(":")}) tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("invalid")}) tm.Send(tea.KeyMsg{Type: tea.KeyEnter}) teatest.RequireEqualOutput(t, read(t, tm)) tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("q")}) tm.WaitFinished(t, teatest.WithFinalTimeout(time.Second)) } func TestGotoLineInputGreaterThanTotalLines(t *testing.T) { tm := prepare(t, options{showLineNumbers: true}) tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(":")}) tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("500")}) tm.Send(tea.KeyMsg{Type: tea.KeyEnter}) teatest.RequireEqualOutput(t, read(t, tm)) tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("q")}) tm.WaitFinished(t, teatest.WithFinalTimeout(time.Second)) } func TestGotoLineInputLessThanOne(t *testing.T) { tm := prepare(t, options{showLineNumbers: true}) tm.Send(tea.KeyMsg{Type: tea.KeyDown}) tm.Send(tea.KeyMsg{Type: tea.KeyDown}) tm.Send(tea.KeyMsg{Type: tea.KeyDown}) tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(":")}) tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("-2")}) tm.Send(tea.KeyMsg{Type: tea.KeyEnter}) teatest.RequireEqualOutput(t, read(t, tm)) tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("q")}) tm.WaitFinished(t, teatest.WithFinalTimeout(time.Second)) } func TestGotoLineKeepsHistory(t *testing.T) { tm := prepare(t, options{showLineNumbers: true}) tm.Send(tea.KeyMsg{Type: tea.KeyDown}) tm.Send(tea.KeyMsg{Type: tea.KeyDown}) tm.Send(tea.KeyMsg{Type: tea.KeyDown}) tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(":")}) tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("4")}) tm.Send(tea.KeyMsg{Type: tea.KeyEnter}) tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(":")}) tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("14")}) tm.Send(tea.KeyMsg{Type: tea.KeyEnter}) tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("[")}) teatest.RequireEqualOutput(t, read(t, tm)) tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("q")}) tm.WaitFinished(t, teatest.WithFinalTimeout(time.Second)) }