init jnv project

This commit is contained in:
ynqa
2024-03-19 22:39:39 +09:00
commit 57a8e9d01d
16 changed files with 1594 additions and 0 deletions

20
.devcontainer/Dockerfile Normal file
View File

@@ -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++

View File

@@ -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"
}

61
.github/workflows/ci.yml vendored Normal file
View File

@@ -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

307
.github/workflows/release.yml vendored Normal file
View File

@@ -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<<EOF" >> "$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<<EOF" >> "$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/*"

17
.gitignore vendored Normal file
View File

@@ -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

128
CODE_OF_CONDUCT.md Normal file
View File

@@ -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.

59
CONTRIBUTING.md Normal file
View File

@@ -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 `<your_username>` with your GitHub username:
```bash
git clone https://github.com/<your_username>/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"!

42
Cargo.toml Normal file
View File

@@ -0,0 +1,42 @@
[package]
name = "jnv"
version = "0.1.0"
authors = ["ynqa <un.pensiero.vano@gmail.com>"]
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 = '*'

20
LICENSE Normal file
View File

@@ -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.

123
README.md Normal file
View File

@@ -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
| :- | :-
| <kbd>Ctrl + C</kbd> | Exit `jnv`
| <kbd>Tab</kbd> | jq filter auto-completion
| <kbd>←</kbd> | Move the cursor one character to the left
| <kbd>→</kbd> | Move the cursor one character to the right
| <kbd>Ctrl + A</kbd> | Move the cursor to the start of the filter
| <kbd>Ctrl + E</kbd> | Move the cursor to the end of the filter
| <kbd>Backspace</kbd> | Delete a character of filter at the cursor position
| <kbd>Ctrl + U</kbd> | Delete all characters of filter
| <kbd>↑</kbd>, <kbd>Ctrl + K</kbd> | Move the cursor one entry up in JSON viewer
| <kbd>↓</kbd>, <kbd>Ctrl + J</kbd> | Move the cursor one entry down in JSON viewer
| <kbd>Ctrl + H</kbd> | Move to the last entry in JSON viewer
| <kbd>Ctrl + L</kbd> | Move to the first entry in JSON viewer
| <kbd>Enter</kbd> | Toggle expand/collapse in JSON viewer
| <kbd>Ctrl + P</kbd> | Expand all folds in JSON viewer
| <kbd>Ctrl + N</kbd> | 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 <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 <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 <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 <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
```

293
src/jnv.rs Normal file
View File

@@ -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<usize>,
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<self::render::Renderer>,
}
impl Jnv {
pub fn try_new(
input_json: String,
expand_depth: Option<usize>,
no_hint: bool,
edit_mode: text_editor::Mode,
indent: usize,
suggestion_list_length: usize,
) -> Result<Self> {
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::<String>()
}
});
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::<String>::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<Prompt<String>> {
let trie = RefCell::new(QueryTrie::default());
Ok(Prompt::try_new(
Box::new(self::render::Renderer {
keymap: self.keymap,
query_editor_snapshot: Snapshot::<text_editor::Renderer>::new(
self.query_editor_renderer,
),
hint_message_snapshot: Snapshot::<text::Renderer>::new(self.hint_message_renderer),
suggest: self.suggest,
suggest_snapshot: Snapshot::<listbox::Renderer>::new(self.suggest_renderer),
json_bundle_snapshot: Snapshot::<json::bundle::Renderer>::new(
self.json_bundle_renderer,
),
}),
Box::new(
move |event: &Event,
renderer: &mut Box<dyn Renderer + 'static>|
-> promkit::Result<PromptSignal> {
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 <top-level>, 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::<Result<Vec<JsonNode>, _>>()
.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<String> {
Ok(self::render::Renderer::cast(renderer)?
.query_editor_snapshot
.after()
.texteditor
.text_without_cursor()
.to_string())
},
)?)
}
}

254
src/jnv/keymap.rs Normal file
View File

@@ -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<PromptSignal> {
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<PromptSignal> {
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::<String>::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)
}

27
src/jnv/render.rs Normal file
View File

@@ -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<Self>,
pub query_editor_snapshot: Snapshot<text_editor::Renderer>,
pub hint_message_snapshot: Snapshot<text::Renderer>,
pub suggest: Suggest,
pub suggest_snapshot: Snapshot<listbox::Renderer>,
pub json_bundle_snapshot: Snapshot<json::bundle::Renderer>,
}
impl_as_any!(Renderer);
impl_cast!(Renderer);
impl promkit::Renderer for Renderer {
fn create_panes(&self, width: u16) -> Vec<Pane> {
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
}
}

22
src/jnv/trie.rs Normal file
View File

@@ -0,0 +1,22 @@
use radix_trie::{Trie, TrieCommon};
use promkit::json::JsonNode;
#[derive(Default)]
pub struct QueryTrie(Trie<String, Vec<JsonNode>>);
impl QueryTrie {
pub fn insert(&mut self, query: &str, json_nodes: Vec<JsonNode>) {
self.0.insert(query.to_string(), json_nodes);
}
pub fn prefix_search(&self, query: &str) -> Option<(&String, &Vec<JsonNode>)> {
self.0
.get_ancestor(query)
.and_then(|subtrie| Some((subtrie.key()?, subtrie.value()?)))
}
pub fn prefix_search_value(&self, query: &str) -> Option<&Vec<JsonNode>> {
self.prefix_search(query).map(|tup| tup.1)
}
}

155
src/main.rs Normal file
View File

@@ -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<PathBuf>,
#[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<usize>,
#[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<text_editor::Mode> {
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<String> {
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(())
}

23
tapes/demo.tape Normal file
View File

@@ -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