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)

## 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)
================================================
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
[7m{[0m[K
"title": "Lorem ipsum",[K
"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.",[K
"tags": […],[K
"year": 3000,[K
"funny": true,[K
"author": {"name":"John Doe",…}[K
}[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
1% [80D
================================================
FILE: testdata/TestCollapseRecursiveWithSizes.golden
================================================
[?25l[?2004h
[7m{[0m (6 keys)[K
"title": "Lorem ipsum",[K
"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.",[K
"tags": […], (3 items)[K
"year": 3000,[K
"funny": true,[K
"author": {"name":"John Doe",…} (2 keys)[K
}[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
1% [80D
================================================
FILE: testdata/TestGotoLine.golden
================================================
[?25l[?2004h
[90m 1[0m {[K
[90m 2[0m "title": "Lorem ipsum",[K
[90m 3[0m "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.",[K
[90m 4[0m "tags": [[K
[90m 5[0m [7m"lorem"[0m,[K
[90m 6[0m "ipsum",[K
[90m 7[0m null[K
[90m 8[0m ],[K
[90m 9[0m "year": 3000,[K
[90m10[0m "funny": true,[K
[90m11[0m "author": {[K
[90m12[0m "name": "John Doe",[K
[90m13[0m "email": "john@doe.com"[K
[90m14[0m }[K
[90m15[0m }[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
.tags[0] 33% [80D
================================================
FILE: testdata/TestGotoLineCollapsed.golden
================================================
[?25l[?2004h
[90m 1[0m {[K
[90m 2[0m "title": "Lorem ipsum",[K
[90m 3[0m "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.",[K
[90m 4[0m "tags": [[K
[90m 5[0m [7m"lorem"[0m,[K
[90m 6[0m "ipsum",[K
[90m 7[0m null[K
[90m 8[0m ],[K
[90m 9[0m "year": 3000,[K
[90m10[0m "funny": true,[K
[90m11[0m "author": {"name":"John Doe",…}[K
[90m15[0m }[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
.tags[0] 33% [80D
================================================
FILE: testdata/TestGotoLineInputGreaterThanTotalLines.golden
================================================
[?25l[?2004h
[90m 1[0m {[K
[90m 2[0m "title": "Lorem ipsum",[K
[90m 3[0m "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.",[K
[90m 4[0m "tags": [[K
[90m 5[0m "lorem",[K
[90m 6[0m "ipsum",[K
[90m 7[0m null[K
[90m 8[0m ],[K
[90m 9[0m "year": 3000,[K
[90m10[0m "funny": true,[K
[90m11[0m "author": {[K
[90m12[0m "name": "John Doe",[K
[90m13[0m "email": "john@doe.com"[K
[90m14[0m }[K
[90m15[0m [7m}[0m[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
100% [80D
================================================
FILE: testdata/TestGotoLineInputInvalid.golden
================================================
[?25l[?2004h
[90m 1[0m {[K
[90m 2[0m "title": "Lorem ipsum",[K
[90m 3[0m "text": "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do e
[7miusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad mini[0m
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.",[K
[90m 4[0m "tags": […],[K
[90m 9[0m "year": 3000,[K
[90m10[0m "funny": true,[K
[90m11[0m "author": {"name":"John Doe",…}[K
[90m15[0m }[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
.text 20% [80D
================================================
FILE: testdata/TestGotoLineInputLessThanOne.golden
================================================
[?25l[?2004h
[90m 1[0m [7m{[0m[K
[90m 2[0m "title": "Lorem ipsum",[K
[90m 3[0m "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.",[K
[90m 4[0m "tags": [[K
[90m 5[0m "lorem",[K
[90m 6[0m "ipsum",[K
[90m 7[0m null[K
[90m 8[0m ],[K
[90m 9[0m "year": 3000,[K
[90m10[0m "funny": true,[K
[90m11[0m "author": {[K
[90m12[0m "name": "John Doe",[K
[90m13[0m "email": "john@doe.com"[K
[90m14[0m }[K
[90m15[0m }[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
1% [80D
================================================
FILE: testdata/TestGotoLineKeepsHistory.golden
================================================
[?25l[?2004h
[90m 1[0m {[K
[90m 2[0m "title": "Lorem ipsum",[K
[90m 3[0m "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.",[K
[90m 4[0m [7m"tags"[0m: [[K
[90m 5[0m "lorem",[K
[90m 6[0m "ipsum",[K
[90m 7[0m null[K
[90m 8[0m ],[K
[90m 9[0m "year": 3000,[K
[90m10[0m "funny": true,[K
[90m11[0m "author": {[K
[90m12[0m "name": "John Doe",[K
[90m13[0m "email": "john@doe.com"[K
[90m14[0m }[K
[90m15[0m }[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
.tags 26% [80D
================================================
FILE: testdata/TestNavigation.golden
================================================
[?25l[?2004h
{[K
"title": "Lorem ipsum",[K
"text": "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusm
[7mod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam[0m
, 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.",[K
"tags": [[K
"lorem",[K
"ipsum",[K
null[K
],[K
"year": 3000,[K
"funny": true,[K
"author": {[K
"name": "John Doe",[K
"email": "john@doe.com"[K
}[K
}[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
.text 20% [80D
================================================
FILE: testdata/TestOutput.golden
================================================
[?25l[?2004h
[7m{[0m[K
"title": "Lorem ipsum",[K
"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.",[K
"tags": [[K
"lorem",[K
"ipsum",[K
null[K
],[K
"year": 3000,[K
"funny": true,[K
"author": {[K
"name": "John Doe",[K
"email": "john@doe.com"[K
}[K
}[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
~[K
1% [80D
================================================
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))
}