mirror of
https://github.com/ynqa/jnv.git
synced 2025-12-16 16:04:11 -06:00
init jnv project
This commit is contained in:
20
.devcontainer/Dockerfile
Normal file
20
.devcontainer/Dockerfile
Normal 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++
|
||||
43
.devcontainer/devcontainer.json
Normal file
43
.devcontainer/devcontainer.json
Normal 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
61
.github/workflows/ci.yml
vendored
Normal 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
307
.github/workflows/release.yml
vendored
Normal 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
17
.gitignore
vendored
Normal 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
128
CODE_OF_CONDUCT.md
Normal 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
59
CONTRIBUTING.md
Normal 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
42
Cargo.toml
Normal 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
20
LICENSE
Normal 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
123
README.md
Normal file
@@ -0,0 +1,123 @@
|
||||
# jnv
|
||||
|
||||
*jnv* is designed for navigating JSON,
|
||||
offering an interactive JSON viewer and `jq` filter editor.
|
||||
|
||||

|
||||
|
||||
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
293
src/jnv.rs
Normal 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
254
src/jnv/keymap.rs
Normal 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
27
src/jnv/render.rs
Normal 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
22
src/jnv/trie.rs
Normal 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
155
src/main.rs
Normal 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
23
tapes/demo.tape
Normal 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
|
||||
Reference in New Issue
Block a user