commit 57a8e9d01d3019aeb5b86fe761f6e7014939667a Author: ynqa Date: Tue Mar 19 22:39:39 2024 +0900 init `jnv` project diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 0000000..6bb9fa1 --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,20 @@ +FROM mcr.microsoft.com/devcontainers/rust:1-1-bullseye + +# Clang 15 +RUN apt-get update \ + && apt-get install -y \ + build-essential \ + autoconf \ + libtool \ + git \ + wget \ + software-properties-common \ + && wget -O - https://apt.llvm.org/llvm-snapshot.gpg.key|apt-key add - \ + && apt-add-repository "deb http://apt.llvm.org/bullseye/ llvm-toolchain-bullseye-15 main" \ + && apt-get update \ + && apt-get install -y clang-15 lldb-15 lld-15 \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +RUN ln -s /usr/bin/clang-15 /usr/bin/clang \ + && ln -s /usr/bin/clang++-15 /usr/bin/clang++ diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..7bb0201 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,43 @@ +// For format details, see https://aka.ms/devcontainer.json. For config options, see the +// README at: https://github.com/devcontainers/templates/tree/main/src/rust +{ + "name": "Rust", + // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile + // "image": "mcr.microsoft.com/devcontainers/rust:1-1-bullseye", + "build": { + // Path is relative to the devcontainer.json file. + "dockerfile": "Dockerfile" + }, + + "customizations": { + "vscode": { + "extensions": [ + "rust-lang.rust-analyzer" + ] + } + } + + // Use 'mounts' to make the cargo cache persistent in a Docker Volume. + // "mounts": [ + // { + // "source": "devcontainer-cargo-cache-${devcontainerId}", + // "target": "/usr/local/cargo", + // "type": "volume" + // } + // ] + + // Features to add to the dev container. More info: https://containers.dev/features. + // "features": {}, + + // Use 'forwardPorts' to make a list of ports inside the container available locally. + // "forwardPorts": [], + + // Use 'postCreateCommand' to run commands after the container is created. + // "postCreateCommand": "rustc --version", + + // Configure tool-specific properties. + // "customizations": {}, + + // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. + // "remoteUser": "root" +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..924e7c9 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,61 @@ +name: ci + +on: [push] + +jobs: + test: + name: test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: actions/cache@v2 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + - name: Install clang package + run: | + sudo apt-get update + sudo apt-get install -y \ + build-essential \ + autoconf \ + libtool \ + git \ + wget \ + software-properties-common + wget -O - https://apt.llvm.org/llvm-snapshot.gpg.key | sudo apt-key add - + sudo apt-add-repository "deb http://apt.llvm.org/bullseye/ llvm-toolchain-bullseye-15 main" + sudo apt-get update + sudo apt-get install -y \ + clang-15 \ + lldb-15 \ + lld-15 + - name: Set default compiler to clang + run: | + echo "CC=clang-15" >> $GITHUB_ENV + echo "CXX=clang++-15" >> $GITHUB_ENV + - uses: actions-rs/toolchain@v1 + with: + toolchain: stable + components: rustfmt, clippy + - uses: actions-rs/cargo@v1 + with: + command: fmt + args: --all -- --check + - uses: actions-rs/cargo@v1 + with: + command: clippy + - uses: actions-rs/cargo@v1 + with: + command: test + args: -- --nocapture --format pretty + - uses: actions-rs/cargo@v1 + with: + command: build + args: --examples + - uses: actions-rs/cargo@v1 + with: + command: build + args: --bins diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..081e0d5 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,307 @@ +# Copyright 2022-2023, axodotdev +# SPDX-License-Identifier: MIT or Apache-2.0 +# +# CI that: +# +# * checks for a Git Tag that looks like a release +# * builds artifacts with cargo-dist (archives, installers, hashes) +# * uploads those artifacts to temporary workflow zip +# * on success, uploads the artifacts to a Github Release +# +# Note that the Github Release will be created with a generated +# title/body based on your changelogs. + +name: Release + +permissions: + contents: write + +# This task will run whenever you push a git tag that looks like a version +# like "1.0.0", "v0.1.0-prerelease.1", "my-app/0.1.0", "releases/v1.0.0", etc. +# Various formats will be parsed into a VERSION and an optional PACKAGE_NAME, where +# PACKAGE_NAME must be the name of a Cargo package in your workspace, and VERSION +# must be a Cargo-style SemVer Version (must have at least major.minor.patch). +# +# If PACKAGE_NAME is specified, then the announcement will be for that +# package (erroring out if it doesn't have the given version or isn't cargo-dist-able). +# +# If PACKAGE_NAME isn't specified, then the announcement will be for all +# (cargo-dist-able) packages in the workspace with that version (this mode is +# intended for workspaces with only one dist-able package, or with all dist-able +# packages versioned/released in lockstep). +# +# If you push multiple tags at once, separate instances of this workflow will +# spin up, creating an independent announcement for each one. However Github +# will hard limit this to 3 tags per commit, as it will assume more tags is a +# mistake. +# +# If there's a prerelease-style suffix to the version, then the release(s) +# will be marked as a prerelease. +on: + push: + tags: + - '**[0-9]+.[0-9]+.[0-9]+*' + pull_request: + +jobs: + # Run 'cargo dist plan' (or host) to determine what tasks we need to do + plan: + runs-on: ubuntu-latest + outputs: + val: ${{ steps.plan.outputs.manifest }} + tag: ${{ !github.event.pull_request && github.ref_name || '' }} + tag-flag: ${{ !github.event.pull_request && format('--tag={0}', github.ref_name) || '' }} + publishing: ${{ !github.event.pull_request }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + - name: Install cargo-dist + # we specify bash to get pipefail; it guards against the `curl` command + # failing. otherwise `sh` won't catch that `curl` returned non-0 + shell: bash + run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.11.1/cargo-dist-installer.sh | sh" + # sure would be cool if github gave us proper conditionals... + # so here's a doubly-nested ternary-via-truthiness to try to provide the best possible + # functionality based on whether this is a pull_request, and whether it's from a fork. + # (PRs run on the *source* but secrets are usually on the *target* -- that's *good* + # but also really annoying to build CI around when it needs secrets to work right.) + - id: plan + run: | + cargo dist ${{ (!github.event.pull_request && format('host --steps=create --tag={0}', github.ref_name)) || 'plan' }} --output-format=json > plan-dist-manifest.json + echo "cargo dist ran successfully" + cat plan-dist-manifest.json + echo "manifest=$(jq -c "." plan-dist-manifest.json)" >> "$GITHUB_OUTPUT" + - name: "Upload dist-manifest.json" + uses: actions/upload-artifact@v4 + with: + name: artifacts-plan-dist-manifest + path: plan-dist-manifest.json + + # Build and packages all the platform-specific things + build-local-artifacts: + name: build-local-artifacts (${{ join(matrix.targets, ', ') }}) + # Let the initial task tell us to not run (currently very blunt) + needs: + - plan + if: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix.include != null && (needs.plan.outputs.publishing == 'true' || fromJson(needs.plan.outputs.val).ci.github.pr_run_mode == 'upload') }} + strategy: + fail-fast: false + # Target platforms/runners are computed by cargo-dist in create-release. + # Each member of the matrix has the following arguments: + # + # - runner: the github runner + # - dist-args: cli flags to pass to cargo dist + # - install-dist: expression to run to install cargo-dist on the runner + # + # Typically there will be: + # - 1 "global" task that builds universal installers + # - N "local" tasks that build each platform's binaries and platform-specific installers + matrix: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix }} + runs-on: ${{ matrix.runner }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + BUILD_MANIFEST_NAME: target/distrib/${{ join(matrix.targets, '-') }}-dist-manifest.json + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + - uses: swatinem/rust-cache@v2 + - name: Install cargo-dist + run: ${{ matrix.install_dist }} + # Get the dist-manifest + - name: Fetch local artifacts + uses: actions/download-artifact@v4 + with: + pattern: artifacts-* + path: target/distrib/ + merge-multiple: true + - name: Install dependencies + run: | + ${{ matrix.packages_install }} + - name: Build artifacts + run: | + # Actually do builds and make zips and whatnot + cargo dist build ${{ needs.plan.outputs.tag-flag }} --print=linkage --output-format=json ${{ matrix.dist_args }} > dist-manifest.json + echo "cargo dist ran successfully" + - id: cargo-dist + name: Post-build + # We force bash here just because github makes it really hard to get values up + # to "real" actions without writing to env-vars, and writing to env-vars has + # inconsistent syntax between shell and powershell. + shell: bash + run: | + # Parse out what we just built and upload it to scratch storage + echo "paths<> "$GITHUB_OUTPUT" + jq --raw-output ".artifacts[]?.path | select( . != null )" dist-manifest.json >> "$GITHUB_OUTPUT" + echo "EOF" >> "$GITHUB_OUTPUT" + + cp dist-manifest.json "$BUILD_MANIFEST_NAME" + - name: "Upload artifacts" + uses: actions/upload-artifact@v4 + with: + name: artifacts-build-local-${{ join(matrix.targets, '_') }} + path: | + ${{ steps.cargo-dist.outputs.paths }} + ${{ env.BUILD_MANIFEST_NAME }} + + # Build and package all the platform-agnostic(ish) things + build-global-artifacts: + needs: + - plan + - build-local-artifacts + runs-on: "ubuntu-20.04" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + BUILD_MANIFEST_NAME: target/distrib/global-dist-manifest.json + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + - name: Install cargo-dist + shell: bash + run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.11.1/cargo-dist-installer.sh | sh" + # Get all the local artifacts for the global tasks to use (for e.g. checksums) + - name: Fetch local artifacts + uses: actions/download-artifact@v4 + with: + pattern: artifacts-* + path: target/distrib/ + merge-multiple: true + - id: cargo-dist + shell: bash + run: | + cargo dist build ${{ needs.plan.outputs.tag-flag }} --output-format=json "--artifacts=global" > dist-manifest.json + echo "cargo dist ran successfully" + + # Parse out what we just built and upload it to scratch storage + echo "paths<> "$GITHUB_OUTPUT" + jq --raw-output ".artifacts[]?.path | select( . != null )" dist-manifest.json >> "$GITHUB_OUTPUT" + echo "EOF" >> "$GITHUB_OUTPUT" + + cp dist-manifest.json "$BUILD_MANIFEST_NAME" + - name: "Upload artifacts" + uses: actions/upload-artifact@v4 + with: + name: artifacts-build-global + path: | + ${{ steps.cargo-dist.outputs.paths }} + ${{ env.BUILD_MANIFEST_NAME }} + # Determines if we should publish/announce + host: + needs: + - plan + - build-local-artifacts + - build-global-artifacts + # Only run if we're "publishing", and only if local and global didn't fail (skipped is fine) + if: ${{ always() && needs.plan.outputs.publishing == 'true' && (needs.build-global-artifacts.result == 'skipped' || needs.build-global-artifacts.result == 'success') && (needs.build-local-artifacts.result == 'skipped' || needs.build-local-artifacts.result == 'success') }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + runs-on: "ubuntu-20.04" + outputs: + val: ${{ steps.host.outputs.manifest }} + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + - name: Install cargo-dist + run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.11.1/cargo-dist-installer.sh | sh" + # Fetch artifacts from scratch-storage + - name: Fetch artifacts + uses: actions/download-artifact@v4 + with: + pattern: artifacts-* + path: target/distrib/ + merge-multiple: true + # This is a harmless no-op for Github Releases, hosting for that happens in "announce" + - id: host + shell: bash + run: | + cargo dist host ${{ needs.plan.outputs.tag-flag }} --steps=upload --steps=release --output-format=json > dist-manifest.json + echo "artifacts uploaded and released successfully" + cat dist-manifest.json + echo "manifest=$(jq -c "." dist-manifest.json)" >> "$GITHUB_OUTPUT" + - name: "Upload dist-manifest.json" + uses: actions/upload-artifact@v4 + with: + # Overwrite the previous copy + name: artifacts-dist-manifest + path: dist-manifest.json + + publish-homebrew-formula: + needs: + - plan + - host + runs-on: "ubuntu-20.04" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PLAN: ${{ needs.plan.outputs.val }} + GITHUB_USER: "axo bot" + GITHUB_EMAIL: "admin+bot@axo.dev" + if: ${{ !fromJson(needs.plan.outputs.val).announcement_is_prerelease || fromJson(needs.plan.outputs.val).publish_prereleases }} + steps: + - uses: actions/checkout@v4 + with: + repository: "ynqa/homebrew-tap" + token: ${{ secrets.HOMEBREW_TAP_TOKEN }} + # So we have access to the formula + - name: Fetch local artifacts + uses: actions/download-artifact@v4 + with: + pattern: artifacts-* + path: Formula/ + merge-multiple: true + # This is extra complex because you can make your Formula name not match your app name + # so we need to find releases with a *.rb file, and publish with that filename. + - name: Commit formula files + run: | + git config --global user.name "${GITHUB_USER}" + git config --global user.email "${GITHUB_EMAIL}" + + for release in $(echo "$PLAN" | jq --compact-output '.releases[] | select([.artifacts[] | endswith(".rb")] | any)'); do + filename=$(echo "$release" | jq '.artifacts[] | select(endswith(".rb"))' --raw-output) + name=$(echo "$filename" | sed "s/\.rb$//") + version=$(echo "$release" | jq .app_version --raw-output) + + git add "Formula/${filename}" + git commit -m "${name} ${version}" + done + git push + + # Create a Github Release while uploading all files to it + announce: + needs: + - plan + - host + - publish-homebrew-formula + # use "always() && ..." to allow us to wait for all publish jobs while + # still allowing individual publish jobs to skip themselves (for prereleases). + # "host" however must run to completion, no skipping allowed! + if: ${{ always() && needs.host.result == 'success' && (needs.publish-homebrew-formula.result == 'skipped' || needs.publish-homebrew-formula.result == 'success') }} + runs-on: "ubuntu-20.04" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + - name: "Download Github Artifacts" + uses: actions/download-artifact@v4 + with: + pattern: artifacts-* + path: artifacts + merge-multiple: true + - name: Cleanup + run: | + # Remove the granular manifests + rm -f artifacts/*-dist-manifest.json + - name: Create Github Release + uses: ncipollo/release-action@v1 + with: + tag: ${{ needs.plan.outputs.tag }} + name: ${{ fromJson(needs.host.outputs.val).announcement_title }} + body: ${{ fromJson(needs.host.outputs.val).announcement_github_body }} + prerelease: ${{ fromJson(needs.host.outputs.val).announcement_is_prerelease }} + artifacts: "artifacts/*" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..42d75df --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +# Generated by Cargo +# will have compiled files and executables +debug/ +target/ + +# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries +# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html +Cargo.lock + +# These are backup files generated by rustfmt +**/*.rs.bk + +# MSVC Windows builds of rustc generate these, which store debugging information +*.pdb + +# Ignore GIF files in the tapes directory +tapes/*.gif diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..988be2e --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,128 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity +and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the + overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or + advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email + address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +email (un.pensiero.vano@gmail.com). +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series +of actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or +permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within +the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.0, available at +https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct +enforcement ladder](https://github.com/mozilla/diversity). + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see the FAQ at +https://www.contributor-covenant.org/faq. Translations are available at +https://www.contributor-covenant.org/translations. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..d3d4a38 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,59 @@ +# Contributing to jnv + +We welcome contributions to "jnv" and greatly appreciate your help in making +this project even better. Here's a quick guide to get you started. + +## How to Contribute + +1. **Fork the Repository**: Click the "Fork" button at the top right of the +[jnv repository](https://github.com/ynqa/jnv) to create a copy of the +project in your GitHub account. + +2. **Clone the Repository**: On your local machine, open a terminal and run the +following command, replacing `` with your GitHub username: + +```bash +git clone https://github.com//jnv.git +``` + +3. **Create a Branch**: Before making any changes, create a new branch for your +work: + +```bash +git checkout -b your-branch-name +``` + +4. **Make Changes**: Make your desired code changes, bug fixes, or feature +additions. + +5. **Commit Your Changes**: Commit your changes with a clear and concise message +explaining the purpose of your contribution: + +```bash +git commit -m "Your commit message here" +``` + +6. **Push to Your Fork**: Push your changes to your forked repository on GitHub: + +```bash +git push origin your-branch-name +``` + +7. **Create a Pull Request (PR)**: Open the +[jnv Pull Request page](https://github.com/ynqa/jnv/pulls) and click the +"New Pull Request" button. Compare and create your PR by following the prompts. + +8. **Review and Discuss**: Your PR will be reviewed by project maintainers, who +may provide feedback or request further changes. Be prepared for discussion and +updates. + +9. **Merging**: Once your PR is approved and passes any necessary tests, a +project maintainer will merge it into the main repository. + +## Code of Conduct + +Please adhere to our [Code of Conduct](CODE_OF_CONDUCT.md) when participating in +this project. We aim to create a respectful and inclusive community for all +contributors. + +Thank you for considering contributing to "jnv"! diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..3771403 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,42 @@ +[package] +name = "jnv" +version = "0.1.0" +authors = ["ynqa "] +edition = "2021" +description = "JSON navigator and interactive filter leveraging jq" +repository = "https://github.com/ynqa/jnv" +license = "MIT" +readme = "README.md" + +[dependencies] +anyhow = "1.0.80" +clap = { version = "4.5.1", features = ["derive"] } +gag = "1.0.0" +j9 = "0.1.1" +promkit = "0.3.1" +radix_trie = "0.2.1" + +# The profile that 'cargo dist' will build with +[profile.dist] +inherits = "release" +lto = "thin" + +# Config for 'cargo dist' +[workspace.metadata.dist] +# The preferred cargo-dist version to use in CI (Cargo.toml SemVer syntax) +cargo-dist-version = "0.11.1" +# CI backends to support +ci = ["github"] +# The installers to generate for each app +installers = ["homebrew"] +# A GitHub repo to push Homebrew formulas to +tap = "ynqa/homebrew-tap" +# Target platforms to build apps for (Rust target-triple syntax) +targets = ["x86_64-apple-darwin"] +# Publish jobs to run in CI +publish-jobs = ["homebrew"] +# Publish jobs to run in CI +pr-run-mode = "plan" + +[workspace.metadata.dist.dependencies.homebrew] +automake = '*' diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c0db342 --- /dev/null +++ b/LICENSE @@ -0,0 +1,20 @@ +The MIT License (MIT) + +Copyright (c) 2024 jnv authors + +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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..dc9cb8f --- /dev/null +++ b/README.md @@ -0,0 +1,123 @@ +# jnv + +*jnv* is designed for navigating JSON, +offering an interactive JSON viewer and `jq` filter editor. + +![demo](https://github.com/ynqa/jnv/assets/6745370/1d1495e8-5755-487f-bbf3-03e1d4edab08) + +Inspired by [jid](https://github.com/simeji/jid) +and [jiq](https://github.com/fiatjaf/jiq). + +## Features + +- Interactive JSON viewer and `jq` filter editor + - Syntax highlighting for JSON +- Accept JSON from stdin, file, URL +- Auto-completion for the filter + - Only supports: + - [Identity](https://jqlang.github.io/jq/manual/#identity) + - [Object Identifier-Index](https://jqlang.github.io/jq/manual/#object-identifier-index) + - [Array Index](https://jqlang.github.io/jq/manual/#array-index) +- Hint message to evaluate the filter + +## Installation + +### Homebrew + +```bash +brew install ynqa/tap/jnv +``` + +### Cargo + +```bash +cargo install jnv +``` + +> [!NOTE] +> *jnv* does not require users to install `jq` on their system, +> because it utilizes [j9](https://github.com/ynqa/j9) Rust bindings. + +## Examples + +```bash +cat data.json | jnv +``` + +Or + +```bash +jnv data.json +``` + +## Keymap + +| Key | Action +| :- | :- +| Ctrl + C | Exit `jnv` +| Tab | jq filter auto-completion +| | Move the cursor one character to the left +| | Move the cursor one character to the right +| Ctrl + A | Move the cursor to the start of the filter +| Ctrl + E | Move the cursor to the end of the filter +| Backspace | Delete a character of filter at the cursor position +| Ctrl + U | Delete all characters of filter +| , Ctrl + K | Move the cursor one entry up in JSON viewer +| , Ctrl + J | Move the cursor one entry down in JSON viewer +| Ctrl + H | Move to the last entry in JSON viewer +| Ctrl + L | Move to the first entry in JSON viewer +| Enter | Toggle expand/collapse in JSON viewer +| Ctrl + P | Expand all folds in JSON viewer +| Ctrl + N | Collapse all folds in JSON viewer + +## Usage + +```bash +JSON navigator and interactive filter leveraging jq + +Usage: jnv [OPTIONS] [INPUT] + +Examples: +- Read from a file: + jnv data.json + +- Read from standard input: + cat data.json | jnv + +Arguments: + [INPUT] + Optional path to a JSON file. If not provided or if "-" is specified, reads from standard input + +Options: + -e, --edit-mode + Specifies the edit mode for the interface. + Acceptable values are "insert" or "overwrite". + - "insert" inserts a new input at the cursor's position. + - "overwrite" mode replaces existing characters with new input at the cursor's position. + [default: insert] + + -i, --indent + Affect the formatting of the displayed JSON, + making it more readable by adjusting the indentation level. + [default: 2] + + -n, --no-hint + When this option is enabled, it prevents the display of + hints that typically guide or offer suggestions to the user. + + -d, --expand-depth + Specifies the initial depth to which JSON nodes are expanded in the visualization. + Note: Increasing this depth can significantly slow down the display for large datasets. + [default: 3] + + -l, --suggestion-list-length + Controls the number of suggestions displayed in the list, + aiding users in making selections more efficiently. + [default: 3] + + -h, --help + Print help (see a summary with '-h') + + -V, --version + Print version +``` diff --git a/src/jnv.rs b/src/jnv.rs new file mode 100644 index 0000000..a55956a --- /dev/null +++ b/src/jnv.rs @@ -0,0 +1,293 @@ +use std::cell::RefCell; + +use anyhow::{anyhow, Result}; +use gag::Gag; + +use promkit::{ + crossterm::{ + event::Event, + style::{Attribute, Attributes, Color}, + }, + json::{self, JsonBundle, JsonNode, JsonPathSegment}, + keymap::KeymapManager, + listbox, serde_json, + snapshot::Snapshot, + style::StyleBuilder, + suggest::Suggest, + text, text_editor, Prompt, PromptSignal, Renderer, +}; + +mod keymap; +mod render; +mod trie; +use trie::QueryTrie; + +pub struct Jnv { + input_json: String, + expand_depth: Option, + no_hint: bool, + + query_editor_renderer: text_editor::Renderer, + hint_message_renderer: text::Renderer, + suggest: Suggest, + suggest_renderer: listbox::Renderer, + json_bundle_renderer: json::bundle::Renderer, + keymap: KeymapManager, +} + +impl Jnv { + pub fn try_new( + input_json: String, + expand_depth: Option, + no_hint: bool, + edit_mode: text_editor::Mode, + indent: usize, + suggestion_list_length: usize, + ) -> Result { + let kinds = JsonNode::try_new(input_json.clone(), None)?.flatten_visibles(); + let full = kinds.iter().filter_map(|kind| kind.path()).map(|segments| { + if segments.is_empty() { + ".".to_string() + } else { + segments + .iter() + .map(|segment| match segment { + JsonPathSegment::Key(key) => { + if key.contains('.') || key.contains('-') { + format!(".\"{}\"", key) + } else { + format!(".{}", key) + } + } + JsonPathSegment::Index(index) => format!("[{}]", index), + }) + .collect::() + } + }); + + Ok(Self { + input_json: input_json.clone(), + expand_depth, + no_hint, + query_editor_renderer: text_editor::Renderer { + texteditor: Default::default(), + history: Default::default(), + prefix: String::from("❯❯ "), + mask: Default::default(), + prefix_style: StyleBuilder::new().fgc(Color::DarkCyan).build(), + active_char_style: StyleBuilder::new().bgc(Color::DarkMagenta).build(), + inactive_char_style: StyleBuilder::new().build(), + edit_mode, + lines: Default::default(), + }, + hint_message_renderer: text::Renderer { + text: Default::default(), + style: StyleBuilder::new() + .fgc(Color::DarkGreen) + .attrs(Attributes::from(Attribute::Bold)) + .build(), + }, + suggest: Suggest::from_iter(full), + suggest_renderer: listbox::Renderer { + listbox: listbox::Listbox::from_iter(Vec::::new()), + cursor: String::from("❯ "), + active_item_style: StyleBuilder::new() + .fgc(Color::DarkGrey) + .bgc(Color::DarkYellow) + .build(), + inactive_item_style: StyleBuilder::new().fgc(Color::DarkGrey).build(), + lines: Some(suggestion_list_length), + }, + keymap: KeymapManager::new("default", self::keymap::default) + .register("on_suggest", self::keymap::on_suggest), + json_bundle_renderer: json::bundle::Renderer { + bundle: json::JsonBundle::new([JsonNode::try_new( + j9::run(".", &input_json) + .map_err(|_| { + anyhow!(format!( + "jq error with program: '.', input: {}", + &input_json + )) + })? + .first() + .map(|s| s.as_str()) + .ok_or_else(|| anyhow!("No data found"))?, + expand_depth, + )?]), + theme: json::Theme { + curly_brackets_style: StyleBuilder::new() + .attrs(Attributes::from(Attribute::Bold)) + .build(), + square_brackets_style: StyleBuilder::new() + .attrs(Attributes::from(Attribute::Bold)) + .build(), + key_style: StyleBuilder::new().fgc(Color::DarkBlue).build(), + string_value_style: StyleBuilder::new().fgc(Color::DarkGreen).build(), + number_value_style: StyleBuilder::new().build(), + boolean_value_style: StyleBuilder::new().build(), + null_value_style: StyleBuilder::new().fgc(Color::DarkGrey).build(), + active_item_attribute: Attribute::Bold, + inactive_item_attribute: Attribute::Dim, + lines: Default::default(), + indent, + }, + }, + }) + } + + pub fn prompt(self) -> Result> { + let trie = RefCell::new(QueryTrie::default()); + Ok(Prompt::try_new( + Box::new(self::render::Renderer { + keymap: self.keymap, + query_editor_snapshot: Snapshot::::new( + self.query_editor_renderer, + ), + hint_message_snapshot: Snapshot::::new(self.hint_message_renderer), + suggest: self.suggest, + suggest_snapshot: Snapshot::::new(self.suggest_renderer), + json_bundle_snapshot: Snapshot::::new( + self.json_bundle_renderer, + ), + }), + Box::new( + move |event: &Event, + renderer: &mut Box| + -> promkit::Result { + let renderer = self::render::Renderer::cast_mut(renderer.as_mut())?; + let signal = match renderer.keymap.get() { + Some(f) => f(event, renderer), + None => Ok(PromptSignal::Quit), + }?; + let completed = renderer + .query_editor_snapshot + .after() + .texteditor + .text_without_cursor() + .to_string(); + + if completed + != renderer + .query_editor_snapshot + .borrow_before() + .texteditor + .text_without_cursor() + .to_string() + { + renderer.hint_message_snapshot.reset_after_to_init(); + + // libjq writes to the console when an internal error occurs. + // + // e.g. + // ``` + // let _ = j9::run(". | select(.number == invalid_no_quote)", "{}"); + // jq: error: invalid_no_quote/0 is not defined at , line 1: + // . | select(.number == invalid_no_quote) + // ``` + // + // While errors themselves are not an issue, + // they interfere with the console output handling mechanism + // in promkit and qjq (e.g., causing line numbers to shift). + // Therefore, we'll ignore console output produced inside j9::run. + // + // It's possible that this could be handled + // within github.com/ynqa/j9, but for now, + // we'll proceed with this workaround. + // + // For reference, the functionality of a quiet mode in libjq is + // also being discussed at https://github.com/jqlang/jq/issues/1225. + let ignore_err = Gag::stderr().unwrap(); + let ret = j9::run(&completed, &self.input_json); + drop(ignore_err); + + ret + .map(|ret| { + if ret.is_empty() { + if !self.no_hint { + renderer.hint_message_snapshot.after_mut().replace(text::Renderer { + text: format!("JSON query ('{}') was executed, but no results were returned.", &completed), + style: StyleBuilder::new() + .fgc(Color::DarkRed) + .attrs(Attributes::from(Attribute::Bold)) + .build(), + }); + } + if let Some(searched) = trie.borrow().prefix_search_value(&completed) { + renderer.json_bundle_snapshot.after_mut().bundle = JsonBundle::new(searched.clone()); + } + } else { + ret.iter().map(|string| { + JsonNode::try_new(string.as_str(), self.expand_depth) + }).collect::, _>>() + .map(|nodes| { + if nodes.len() == 1 && nodes.first().unwrap() == &JsonNode::Leaf(serde_json::Value::Null) { + if !self.no_hint { + renderer.hint_message_snapshot.after_mut().replace(text::Renderer { + text: format!( + "JSON query resulted in 'null', which may indicate a typo or incorrect query: '{}'", + &completed, + ), + style: StyleBuilder::new() + .fgc(Color::DarkYellow) + .attrs(Attributes::from(Attribute::Bold)) + .build(), + }); + } + if let Some(searched) = trie.borrow().prefix_search_value(&completed) { + renderer.json_bundle_snapshot.after_mut().bundle = JsonBundle::new(searched.clone()); + } + } else { + // SUCCESS! + trie.borrow_mut().insert(&completed, nodes.clone()); + renderer.json_bundle_snapshot.after_mut().bundle = JsonBundle::new(nodes); + } + }) + .unwrap_or_else(|e| { + if !self.no_hint { + renderer.hint_message_snapshot.after_mut().replace(text::Renderer{ + text: format!( + "Failed to parse query result for viewing: {}", + e + ), + style: StyleBuilder::new() + .fgc(Color::DarkRed) + .attrs(Attributes::from(Attribute::Bold)) + .build(), + }) + } + if let Some(searched) = trie.borrow().prefix_search_value(&completed) { + renderer.json_bundle_snapshot.after_mut().bundle = JsonBundle::new(searched.clone()); + } + }); + } + }) + .unwrap_or_else(|_| { + if !self.no_hint { + renderer.hint_message_snapshot.after_mut().replace(text::Renderer { + text: format!("Failed to execute jq query '{}'", &completed), + style: StyleBuilder::new() + .fgc(Color::DarkRed) + .attrs(Attributes::from(Attribute::Bold)) + .build(), + }, + ); + } + if let Some(searched) = trie.borrow().prefix_search_value(&completed) { + renderer.json_bundle_snapshot.after_mut().bundle = JsonBundle::new(searched.clone()); + } + }); + } + Ok(signal) + }, + ), + |renderer: &(dyn Renderer + '_)| -> promkit::Result { + Ok(self::render::Renderer::cast(renderer)? + .query_editor_snapshot + .after() + .texteditor + .text_without_cursor() + .to_string()) + }, + )?) + } +} diff --git a/src/jnv/keymap.rs b/src/jnv/keymap.rs new file mode 100644 index 0000000..bdcb0c5 --- /dev/null +++ b/src/jnv/keymap.rs @@ -0,0 +1,254 @@ +use promkit::{ + crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers}, + listbox::Listbox, + text_editor, PromptSignal, Result, +}; + +pub fn default(event: &Event, renderer: &mut crate::jnv::render::Renderer) -> Result { + let query_editor_after_mut = renderer.query_editor_snapshot.after_mut(); + let suggest_after_mut = renderer.suggest_snapshot.after_mut(); + let json_bundle_after_mut = renderer.json_bundle_snapshot.after_mut(); + + match event { + Event::Key(KeyEvent { + code: KeyCode::Tab, + modifiers: KeyModifiers::NONE, + kind: KeyEventKind::Press, + state: KeyEventState::NONE, + }) => { + let query = query_editor_after_mut + .texteditor + .text_without_cursor() + .to_string(); + if let Some(mut candidates) = renderer.suggest.prefix_search(query) { + candidates.sort_by(|a, b| a.len().cmp(&b.len()).then_with(|| a.cmp(b))); + + suggest_after_mut.listbox = Listbox::from_iter(candidates); + query_editor_after_mut + .texteditor + .replace(&suggest_after_mut.listbox.get()); + + renderer.keymap.switch("on_suggest"); + } + } + + Event::Key(KeyEvent { + code: KeyCode::Char('c'), + modifiers: KeyModifiers::CONTROL, + kind: KeyEventKind::Press, + state: KeyEventState::NONE, + }) => return Ok(PromptSignal::Quit), + + // Move cursor. + Event::Key(KeyEvent { + code: KeyCode::Left, + modifiers: KeyModifiers::NONE, + kind: KeyEventKind::Press, + state: KeyEventState::NONE, + }) => { + query_editor_after_mut.texteditor.backward(); + } + Event::Key(KeyEvent { + code: KeyCode::Right, + modifiers: KeyModifiers::NONE, + kind: KeyEventKind::Press, + state: KeyEventState::NONE, + }) => { + query_editor_after_mut.texteditor.forward(); + } + Event::Key(KeyEvent { + code: KeyCode::Char('a'), + modifiers: KeyModifiers::CONTROL, + kind: KeyEventKind::Press, + state: KeyEventState::NONE, + }) => query_editor_after_mut.texteditor.move_to_head(), + Event::Key(KeyEvent { + code: KeyCode::Char('e'), + modifiers: KeyModifiers::CONTROL, + kind: KeyEventKind::Press, + state: KeyEventState::NONE, + }) => query_editor_after_mut.texteditor.move_to_tail(), + + // Erase char(s). + Event::Key(KeyEvent { + code: KeyCode::Backspace, + modifiers: KeyModifiers::NONE, + kind: KeyEventKind::Press, + state: KeyEventState::NONE, + }) => query_editor_after_mut.texteditor.erase(), + Event::Key(KeyEvent { + code: KeyCode::Char('u'), + modifiers: KeyModifiers::CONTROL, + kind: KeyEventKind::Press, + state: KeyEventState::NONE, + }) => query_editor_after_mut.texteditor.erase_all(), + + // Move up. + Event::Key(KeyEvent { + code: KeyCode::Up, + modifiers: KeyModifiers::NONE, + kind: KeyEventKind::Press, + state: KeyEventState::NONE, + }) + | Event::Key(KeyEvent { + code: KeyCode::Char('k'), + modifiers: KeyModifiers::CONTROL, + kind: KeyEventKind::Press, + state: KeyEventState::NONE, + }) => { + json_bundle_after_mut.bundle.backward(); + } + + // Move down. + Event::Key(KeyEvent { + code: KeyCode::Down, + modifiers: KeyModifiers::NONE, + kind: KeyEventKind::Press, + state: KeyEventState::NONE, + }) + | Event::Key(KeyEvent { + code: KeyCode::Char('j'), + modifiers: KeyModifiers::CONTROL, + kind: KeyEventKind::Press, + state: KeyEventState::NONE, + }) => { + json_bundle_after_mut.bundle.forward(); + } + + // Move to tail + Event::Key(KeyEvent { + code: KeyCode::Char('h'), + modifiers: KeyModifiers::CONTROL, + kind: KeyEventKind::Press, + state: KeyEventState::NONE, + }) => { + json_bundle_after_mut.bundle.move_to_tail(); + } + + // Move to head + Event::Key(KeyEvent { + code: KeyCode::Char('l'), + modifiers: KeyModifiers::CONTROL, + kind: KeyEventKind::Press, + state: KeyEventState::NONE, + }) => { + json_bundle_after_mut.bundle.move_to_head(); + } + + // Toggle collapse/expand + Event::Key(KeyEvent { + code: KeyCode::Enter, + modifiers: KeyModifiers::NONE, + kind: KeyEventKind::Press, + state: KeyEventState::NONE, + }) => { + json_bundle_after_mut.bundle.toggle(); + } + + Event::Key(KeyEvent { + code: KeyCode::Char('p'), + modifiers: KeyModifiers::CONTROL, + kind: KeyEventKind::Press, + state: KeyEventState::NONE, + }) => { + json_bundle_after_mut.bundle.expand_all(); + } + + Event::Key(KeyEvent { + code: KeyCode::Char('n'), + modifiers: KeyModifiers::CONTROL, + kind: KeyEventKind::Press, + state: KeyEventState::NONE, + }) => { + json_bundle_after_mut.bundle.collapse_all(); + } + + // Input char. + Event::Key(KeyEvent { + code: KeyCode::Char(ch), + modifiers: KeyModifiers::NONE, + kind: KeyEventKind::Press, + state: KeyEventState::NONE, + }) + | Event::Key(KeyEvent { + code: KeyCode::Char(ch), + modifiers: KeyModifiers::SHIFT, + kind: KeyEventKind::Press, + state: KeyEventState::NONE, + }) => match query_editor_after_mut.edit_mode { + text_editor::Mode::Insert => query_editor_after_mut.texteditor.insert(*ch), + text_editor::Mode::Overwrite => query_editor_after_mut.texteditor.overwrite(*ch), + }, + + _ => (), + } + Ok(PromptSignal::Continue) +} + +pub fn on_suggest( + event: &Event, + renderer: &mut crate::jnv::render::Renderer, +) -> Result { + let query_editor_after_mut = renderer.query_editor_snapshot.after_mut(); + let suggest_after_mut = renderer.suggest_snapshot.after_mut(); + + match event { + Event::Key(KeyEvent { + code: KeyCode::Char('c'), + modifiers: KeyModifiers::CONTROL, + kind: KeyEventKind::Press, + state: KeyEventState::NONE, + }) => return Ok(PromptSignal::Quit), + + Event::Key(KeyEvent { + code: KeyCode::Tab, + modifiers: KeyModifiers::NONE, + kind: KeyEventKind::Press, + state: KeyEventState::NONE, + }) + | Event::Key(KeyEvent { + code: KeyCode::Down, + modifiers: KeyModifiers::NONE, + kind: KeyEventKind::Press, + state: KeyEventState::NONE, + }) => { + suggest_after_mut.listbox.forward(); + query_editor_after_mut + .texteditor + .replace(&suggest_after_mut.listbox.get()); + } + + Event::Key(KeyEvent { + code: KeyCode::Up, + modifiers: KeyModifiers::NONE, + kind: KeyEventKind::Press, + state: KeyEventState::NONE, + }) => { + suggest_after_mut.listbox.backward(); + query_editor_after_mut + .texteditor + .replace(&suggest_after_mut.listbox.get()); + } + + _ => { + suggest_after_mut.listbox = Listbox::from_iter(Vec::::new()); + renderer.keymap.switch("default"); + + // This block is specifically designed to prevent the default action of toggling collapse/expand + // from being executed when the Enter key is pressed. This is done from the perspective of user + // experimentation, ensuring that pressing Enter while in the suggest mode does not trigger + // the default behavior associated with the Enter key in the default mode. + if let Event::Key(KeyEvent { + code: KeyCode::Enter, + modifiers: KeyModifiers::NONE, + kind: KeyEventKind::Press, + state: KeyEventState::NONE, + }) = event + { + } else { + return default(event, renderer); + } + } + } + Ok(PromptSignal::Continue) +} diff --git a/src/jnv/render.rs b/src/jnv/render.rs new file mode 100644 index 0000000..af81c7b --- /dev/null +++ b/src/jnv/render.rs @@ -0,0 +1,27 @@ +use promkit::{ + impl_as_any, impl_cast, json, keymap::KeymapManager, listbox, pane::Pane, snapshot::Snapshot, + suggest::Suggest, text, text_editor, +}; + +pub struct Renderer { + pub keymap: KeymapManager, + pub query_editor_snapshot: Snapshot, + pub hint_message_snapshot: Snapshot, + pub suggest: Suggest, + pub suggest_snapshot: Snapshot, + pub json_bundle_snapshot: Snapshot, +} + +impl_as_any!(Renderer); +impl_cast!(Renderer); + +impl promkit::Renderer for Renderer { + fn create_panes(&self, width: u16) -> Vec { + let mut panes = Vec::new(); + panes.extend(self.query_editor_snapshot.create_panes(width)); + panes.extend(self.hint_message_snapshot.create_panes(width)); + panes.extend(self.suggest_snapshot.create_panes(width)); + panes.extend(self.json_bundle_snapshot.create_panes(width)); + panes + } +} diff --git a/src/jnv/trie.rs b/src/jnv/trie.rs new file mode 100644 index 0000000..3c435d9 --- /dev/null +++ b/src/jnv/trie.rs @@ -0,0 +1,22 @@ +use radix_trie::{Trie, TrieCommon}; + +use promkit::json::JsonNode; + +#[derive(Default)] +pub struct QueryTrie(Trie>); + +impl QueryTrie { + pub fn insert(&mut self, query: &str, json_nodes: Vec) { + self.0.insert(query.to_string(), json_nodes); + } + + pub fn prefix_search(&self, query: &str) -> Option<(&String, &Vec)> { + self.0 + .get_ancestor(query) + .and_then(|subtrie| Some((subtrie.key()?, subtrie.value()?))) + } + + pub fn prefix_search_value(&self, query: &str) -> Option<&Vec> { + self.prefix_search(query).map(|tup| tup.1) + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..5f6a8c9 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,155 @@ +use std::{ + fs::File, + io::{self, Read}, + path::PathBuf, +}; + +use anyhow::{anyhow, Result}; +use clap::Parser; + +use promkit::text_editor; + +mod jnv; +use jnv::Jnv; + +/// JSON navigator and interactive filter leveraging jq +#[derive(Parser)] +#[command( + name = "jnv", + version, + help_template = " +{about} + +Usage: {usage} + +Examples: +- Read from a file: + {bin} data.json + +- Read from standard input: + cat data.json | {bin} + +Arguments: +{positionals} + +Options: +{options} +" +)] +pub struct Args { + /// Optional path to a JSON file. + /// If not provided or if "-" is specified, + /// reads from standard input. + pub input: Option, + + #[arg( + short = 'e', + long = "edit-mode", + default_value = "insert", + value_parser = edit_mode_validator, + help = "Edit mode for the interface ('insert' or 'overwrite').", + long_help = r#" + Specifies the edit mode for the interface. + Acceptable values are "insert" or "overwrite". + - "insert" inserts a new input at the cursor's position. + - "overwrite" mode replaces existing characters with new input at the cursor's position. + "#, + )] + pub edit_mode: text_editor::Mode, + + #[arg( + short = 'i', + long = "indent", + default_value = "2", + help = "Number of spaces used for indentation in the visualized data.", + long_help = " + Affect the formatting of the displayed JSON, + making it more readable by adjusting the indentation level. + " + )] + pub indent: usize, + + #[arg( + short = 'n', + long = "no-hint", + help = "Disables the display of hints.", + long_help = " + When this option is enabled, it prevents the display of + hints that typically guide or offer suggestions to the user. + " + )] + pub no_hint: bool, + + #[arg( + short = 'd', + long = "expand-depth", + default_value = "3", + help = "Initial depth to which JSON nodes are expanded in the visualization.", + long_help = " + Specifies the initial depth to which JSON nodes are expanded in the visualization. + Note: Increasing this depth can significantly slow down the display for large datasets. + " + )] + pub expand_depth: Option, + + #[arg( + short = 'l', + long = "suggestion-list-length", + default_value = "3", + help = "Number of suggestions visible in the list.", + long_help = " + Controls the number of suggestions displayed in the list, + aiding users in making selections more efficiently. + " + )] + pub suggestion_list_length: usize, +} + +fn edit_mode_validator(val: &str) -> Result { + match val { + "insert" | "" => Ok(text_editor::Mode::Insert), + "overwrite" => Ok(text_editor::Mode::Overwrite), + _ => Err(anyhow!("edit-mode must be 'insert' or 'overwrite'")), + } +} + +/// Parses the input based on the provided arguments. +/// +/// This function reads input data from either a specified file or standard input. +/// If the `input` argument is `None`, or if it is a path +/// that equals "-", data is read from standard input. +/// Otherwise, the function attempts to open and +/// read from the file specified in the `input` argument. +fn parse_input(args: &Args) -> Result { + let mut ret = String::new(); + + match &args.input { + None => { + io::stdin().read_to_string(&mut ret)?; + } + Some(path) => { + if path == &PathBuf::from("-") { + io::stdin().read_to_string(&mut ret)?; + } else { + File::open(path)?.read_to_string(&mut ret)?; + } + } + } + + Ok(ret) +} + +fn main() -> Result<()> { + let args = Args::parse(); + let mut prompt = Jnv::try_new( + parse_input(&args)?, + args.expand_depth, + args.no_hint, + args.edit_mode, + args.indent, + args.suggestion_list_length, + )? + .prompt()?; + let _ = prompt.run()?; + Ok(()) +} diff --git a/tapes/demo.tape b/tapes/demo.tape new file mode 100644 index 0000000..3d51f8d --- /dev/null +++ b/tapes/demo.tape @@ -0,0 +1,23 @@ +Output tapes/demo.gif + +Require cargo + +Set Shell "bash" +Set Theme "Dracula" +Set FontSize 28 +Set Width 1800 +Set Height 1200 + +Type@100ms "kubectl get pod -n kube-system kube-apiserver-kind-control-plane -ojson | jnv" Enter Sleep 2s +Type ".m" Sleep 1s +Tab 1 Sleep 2s +Down@2s 2 +Up@2s 2 +Enter Sleep 2s +Down@1s 6 +Down@2s 3 +Enter Sleep 2s +Type ".n" Sleep 1s +Tab@1s 3 +Enter Sleep 3s +Ctrl+C