├── .github └── workflows │ ├── ci.yml │ └── release.yml ├── .gitignore ├── CHANGELOG.md ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── RELEASE_CHECKLIST.md ├── SEARCH.md ├── dev-tmux ├── examples └── escaped_strings_for_yanking.json ├── logo ├── github-social-thumbnail.png ├── mascot-floating.svg ├── mascot-indentation.svg ├── mascot-peanut-butter-jelly-sandwich.svg ├── mascot-rocket.svg ├── mascot-rocks-collapsing.svg ├── mascot-searching.svg ├── mascot.svg ├── text-logo-with-mascot.svg └── text-logo.svg └── src ├── app.rs ├── flatjson.rs ├── highlighting.rs ├── input.rs ├── jless.help ├── jsonparser.rs ├── jsonstringunescaper.rs ├── jsontokenizer.rs ├── lineprinter.rs ├── main.rs ├── options.rs ├── render-notes.md ├── screenwriter.rs ├── search.rs ├── terminal.rs ├── truncatedstrview.rs ├── types.rs ├── viewer.rs └── yamlparser.rs /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | on: push 3 | jobs: 4 | test: 5 | name: test 6 | strategy: 7 | matrix: 8 | version: [ 1.67.0, stable ] 9 | platform: 10 | - { os: ubuntu-latest, target: x86_64-unknown-linux-gnu } 11 | - { os: macos-latest , target: x86_64-apple-darwin } 12 | runs-on: ${{ matrix.platform.os }} 13 | steps: 14 | - name: Checkout repository 15 | uses: actions/checkout@v2 16 | - name: Install Rust 17 | uses: actions-rs/toolchain@v1 18 | with: 19 | toolchain: ${{ matrix.version }} 20 | override: true 21 | target: ${{ matrix.platform.target }} 22 | components: clippy, rustfmt 23 | - name: Install clipboard dependencies 24 | if: ${{ matrix.platform.os == 'ubuntu-latest' }} 25 | run: sudo apt install -y libxcb-shape0-dev libxcb-xfixes0-dev 26 | - name: Test 27 | uses: actions-rs/cargo@v1 28 | with: 29 | command: test 30 | args: --target=${{ matrix.platform.target }} 31 | 32 | lint: 33 | name: lint 34 | runs-on: ubuntu-latest 35 | steps: 36 | - name: Checkout repository 37 | uses: actions/checkout@v2 38 | - name: Clippy 39 | uses: actions-rs/cargo@v1 40 | with: 41 | command: clippy 42 | args: -- -D warnings 43 | - name: Format 44 | uses: actions-rs/cargo@v1 45 | with: 46 | command: fmt 47 | args: --all -- --check 48 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | on: 3 | push: 4 | # Enable testing on branches 5 | # branches: 6 | # - test-release 7 | tags: 8 | - "v[0-9]+.[0-9]+.[0-9]+" 9 | jobs: 10 | create-binaries: 11 | name: create-binaries 12 | strategy: 13 | matrix: 14 | platform: 15 | - { os: ubuntu-latest, target: x86_64-unknown-linux-gnu } 16 | - { os: macos-latest , target: x86_64-apple-darwin } 17 | runs-on: ${{ matrix.platform.os }} 18 | steps: 19 | - name: Checkout repository 20 | uses: actions/checkout@v2 21 | - name: Install Rust 22 | uses: actions-rs/toolchain@v1 23 | with: 24 | toolchain: stable 25 | override: true 26 | target: ${{ matrix.platform.target }} 27 | components: clippy, rustfmt 28 | - name: Install clipboard dependencies 29 | if: ${{ matrix.platform.os == 'ubuntu-latest' }} 30 | run: sudo apt install -y libxcb-shape0-dev libxcb-xfixes0-dev 31 | - name: Build 32 | uses: actions-rs/cargo@v1 33 | with: 34 | command: build 35 | args: --verbose --release --target=${{ matrix.platform.target }} 36 | - name: Strip binary 37 | run: strip target/${{ matrix.platform.target }}/release/jless 38 | - name: Compress binary 39 | run: | 40 | mv target/${{ matrix.platform.target }}/release/jless . 41 | zip -X jless-${{ matrix.platform.target }}.zip jless 42 | - name: Upload binary 43 | uses: actions/upload-artifact@v2 44 | with: 45 | name: jless-${{ matrix.platform.target }}.zip 46 | path: jless-${{ matrix.platform.target }}.zip 47 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /target 3 | /dist 4 | TODO 5 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | main 2 | ==== 3 | 4 | Improvements: 5 | - [Issue #143]: `ctrl-z` will now send jless to the background 6 | - `:w[rite] ` and `:w[rite]! ` can be used to write the 7 | current input to a file 8 | - Add a `sexp` feature to gate functionality only used for support of 9 | [OCaml style S-expressions](https://github.com/janestreet/sexplib), or 10 | sexps. 11 | - [feature = "sexp"]: Add `:writesexp ` (also `:ws`) functions for 12 | writing current input as a sexp to a file. This is a temporary 13 | addition and will be removed once proper sexp support is added. 14 | 15 | v0.9.0 (2023-07-16) 16 | ================== 17 | 18 | New features: 19 | - A new command `ys` will copy unescaped string literals to the 20 | clipboard. Control characters remain escaped. 21 | - The length of Arrays and size of Objects is now shown before the 22 | container previews, e.g., (`foo: (3) ["apple", "banana", "cherry"]`) 23 | - Add a new family of "print" commands, that nearly map to the existing 24 | copy commands, that will simply print a value to the screen. This is 25 | useful for viewing the entirety of long string values all at once, or 26 | if the clipboard functionality is not working; mouse-tracking will be 27 | temporarily disabled, allowing you to use your terminal's native 28 | clipboard capabilities to select and copy the desired text. 29 | - Support showing line numbers, both absolute and/or relative. Absolute 30 | line numbers refer to what line number a given node would appear on if 31 | the document were pretty printed. This means there are discontinuities 32 | when in data mode because closing brackets and braces aren't 33 | displayed. Relative line numbers show how far a line is relative to 34 | the currently focused line. The behavior of the various combinations 35 | of these settings matches vim: when using just relative line numbers 36 | alone, the focused line will show `0`, but when both flags are enabled 37 | the focused line will show its absolute line number. 38 | - Absolute line numbers are enabled by default, but not relative line 39 | numbers. These can be enabled/disabled/re-enabled via command line 40 | flags `--line-numbers`, `--no-line-numbers`, 41 | `--relative-line-numbers` and `--no-relative-line-numbers`, or via 42 | the short flags `-n`, `-N`, `-r`, and `-R` respectively. 43 | - These settings can also be modified while jless is running. Entering 44 | `:set number`/`:set relativenumber` will enable these settings, 45 | `:set nonumber`/`:set norelativenumber` will disable them, and 46 | `:set number!`/`:set relativenumber!` will toggle them, matching 47 | vim's behavior. 48 | - There is not yet support for a jless config file, so if you would 49 | like relative line numbers by default, it is recommended to set up 50 | an alias: `alias jless=jless --line-numbers --relative-line-numbers`. 51 | - You can jump to an exact line number using `g` or `G`. 52 | When using `g` (lowercase 'g'), if the desired line number is 53 | hidden inside of a collapsed container, the last visible line number 54 | before the desired one will be focused. When using `G` 55 | (uppercase 'G'), all the ancestors of the desired line will be 56 | expanded to ensure it is visible. 57 | - Add `C` and `E` commands, analogous to the existing `c` and `e` 58 | commands, to deeply collapse/expand a node and all its siblings. 59 | 60 | Improvements: 61 | - In data mode, when a array element is focused, the highlighting on the 62 | index label (e.g., "[8]") is now inverted. Additionally, a '▶' is 63 | always displayed next to the currently focused line, even if the 64 | focused node is a primitive. Together these changes should make it 65 | more clear which line is focused, especially when the terminal's 66 | current style doesn't support dimming (`ESC [ 2 m`). 67 | - When using the `c` and `e` commands (and the new `C` and `E` 68 | commands), the focused row will stay at the same spot on the screen. 69 | (Previously jless would try to keep the same row visible at the top of 70 | the screen, which didn't make sense.) 71 | 72 | Bug fixes: 73 | - Scrolling with the mouse will now move the viewing window, rather than 74 | the cursor. 75 | - When searching, jless will do a better job jumping to the first match 76 | after the cursor; previously if a user started a search while focused 77 | on the opening of a Object or Array, any matches inside that container 78 | were initially skipped over. 79 | - When jumping to a search match that is inside a collapsed container, 80 | search matches will continue to be highlighted after expanding the 81 | container. 82 | - [Issue #71 / PR #98]: jless will return a non-zero exit code if it 83 | fails to parse the input. 84 | 85 | Other notes: 86 | - The minimum supported Rust version has been updated to 1.67. 87 | - jless now re-renders the screen by emitting "clear line" escape codes 88 | (`ESC [ 2 K`) for each line, instead of a single "clear screen" escape 89 | code (`ESC [ 2 J`), in the hopes of reducing flicking when scrolling. 90 | 91 | 92 | v0.8.0 (2022-03-10) 93 | =================== 94 | 95 | New features: 96 | - Implement `ctrl-u` and `ctrl-d` commands to jump up and down by half 97 | the screen's height, or by a specified number of lines. 98 | - Support displaying YAML files with autodetection via file extension, 99 | or explicit `--yaml` or `--json` flags. 100 | - Implement `ctrl-b` and `ctrl-f` commands for scrolling up and down by 101 | the height of the screen. (Aliases for `PageUp` and `PageDown`) 102 | - Support copying values (with `yy` or `yv`), object keys (with `yk`), 103 | and paths to the currently focused node (with `yp`, `yb` or `yq`). 104 | 105 | Improvements: 106 | - Keep focused line in same place on screen when toggling between line 107 | and data modes; fix a crash when focused on a closing delimiter and 108 | switching to data mode. 109 | - Pressing Escape will clear the input buffer and stop highlighting 110 | search matches. 111 | 112 | Bug fixes: 113 | - Ignore clicks on the status bar or below rather than focusing on 114 | hidden lines, and don't re-render the screen, allowing the path in the 115 | status bar to be highlighted and copied. 116 | - [Issue #61]: Display error message for unrecognized CSI escape 117 | sequences and other IO errors instead of panicking. 118 | - [Issue #62]: Fix broken window resizing / SIGWINCH detection caused 119 | by clashing signal handler registered by rustyline. 120 | - [PR #54]: Fix panic when using Ctrl-C or Ctrl-D to cancel entering 121 | search input. 122 | 123 | Other notes: 124 | - Upgraded regex crate to 1.5.5 due to CVE-2022-24713. jless accepts 125 | and compiles untrusted input as regexes, but you'd only DDOS yourself, 126 | so it's not terribly concerning. 127 | 128 | https://blog.rust-lang.org/2022/03/08/cve-2022-24713.html 129 | 130 | 131 | v0.7.2 (2022-02-20) 132 | ================== 133 | 134 | New features / changes: 135 | - [PR #42]: Space now toggles the collapsed state of the currently focused 136 | node, rather than moving down a line. (Functionality was previous 137 | available via `i`, but was undocumented; `i` has become unmapped.) 138 | 139 | Bug fixes: 140 | - [Issue #7 / PR #32]: Fix issue with rustyline always reading from 141 | STDIN preventing `/` command from working when input provided via 142 | STDIN. 143 | 144 | Internal: 145 | - [PR #17]: Upgrade from structopt to clap v3 146 | 147 | 148 | v0.7.1 (2022-02-09) 149 | ================== 150 | 151 | New features: 152 | - F1 now opens help page 153 | - Search initialization commands (/, ?, *, #) all now accept count 154 | arguments 155 | 156 | Internal code cleanup: 157 | - Address a lot of issues reported by clippy 158 | - Remove chunks of unused code, including serde dependency 159 | - Fix typos in help page 160 | 161 | 162 | v0.7.0 (2022-02-06) 163 | ================== 164 | 165 | Introducing jless, a command-line JSON viewer. 166 | 167 | This release represents the significant milestone: a complete set of basic 168 | functionality, without any major bugs. 169 | 170 | [This GitHub issue](https://github.com/PaulJuliusMartinez/jless/issues/1) 171 | details much of the functionality implemented to get to this point. 172 | Spiritually, completion of many of the tasks listed there represent versions 173 | 0.1 - 0.6. 174 | 175 | The intention is to not release a 1.0 version until Windows support is added. 176 | -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 3 4 | 5 | [[package]] 6 | name = "aho-corasick" 7 | version = "0.7.18" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "1e37cfd5e7657ada45f742d6e99ca5788580b5c529dc78faf11ece6dc702656f" 10 | dependencies = [ 11 | "memchr", 12 | ] 13 | 14 | [[package]] 15 | name = "atty" 16 | version = "0.2.14" 17 | source = "registry+https://github.com/rust-lang/crates.io-index" 18 | checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" 19 | dependencies = [ 20 | "hermit-abi", 21 | "libc", 22 | "winapi", 23 | ] 24 | 25 | [[package]] 26 | name = "autocfg" 27 | version = "1.0.1" 28 | source = "registry+https://github.com/rust-lang/crates.io-index" 29 | checksum = "cdb031dd78e28731d87d56cc8ffef4a8f36ca26c38fe2de700543e627f8a464a" 30 | 31 | [[package]] 32 | name = "beef" 33 | version = "0.5.1" 34 | source = "registry+https://github.com/rust-lang/crates.io-index" 35 | checksum = "bed554bd50246729a1ec158d08aa3235d1b69d94ad120ebe187e28894787e736" 36 | 37 | [[package]] 38 | name = "bitflags" 39 | version = "1.2.1" 40 | source = "registry+https://github.com/rust-lang/crates.io-index" 41 | checksum = "cf1de2fe8c75bc145a2f577add951f8134889b4795d47466a54a5c846d691693" 42 | 43 | [[package]] 44 | name = "block" 45 | version = "0.1.6" 46 | source = "registry+https://github.com/rust-lang/crates.io-index" 47 | checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" 48 | 49 | [[package]] 50 | name = "cc" 51 | version = "1.0.69" 52 | source = "registry+https://github.com/rust-lang/crates.io-index" 53 | checksum = "e70cc2f62c6ce1868963827bd677764c62d07c3d9a3e1fb1177ee1a9ab199eb2" 54 | 55 | [[package]] 56 | name = "cfg-if" 57 | version = "0.1.10" 58 | source = "registry+https://github.com/rust-lang/crates.io-index" 59 | checksum = "4785bdd1c96b2a846b2bd7cc02e86b6b3dbf14e7e53446c4f54c92a361040822" 60 | 61 | [[package]] 62 | name = "cfg-if" 63 | version = "1.0.0" 64 | source = "registry+https://github.com/rust-lang/crates.io-index" 65 | checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" 66 | 67 | [[package]] 68 | name = "clap" 69 | version = "4.0.26" 70 | source = "registry+https://github.com/rust-lang/crates.io-index" 71 | checksum = "2148adefda54e14492fb9bddcc600b4344c5d1a3123bd666dcb939c6f0e0e57e" 72 | dependencies = [ 73 | "atty", 74 | "bitflags", 75 | "clap_derive", 76 | "clap_lex", 77 | "once_cell", 78 | "strsim", 79 | "termcolor", 80 | ] 81 | 82 | [[package]] 83 | name = "clap_derive" 84 | version = "4.0.21" 85 | source = "registry+https://github.com/rust-lang/crates.io-index" 86 | checksum = "0177313f9f02afc995627906bbd8967e2be069f5261954222dac78290c2b9014" 87 | dependencies = [ 88 | "heck", 89 | "proc-macro-error", 90 | "proc-macro2", 91 | "quote", 92 | "syn", 93 | ] 94 | 95 | [[package]] 96 | name = "clap_lex" 97 | version = "0.3.3" 98 | source = "registry+https://github.com/rust-lang/crates.io-index" 99 | checksum = "033f6b7a4acb1f358c742aaca805c939ee73b4c6209ae4318ec7aca81c42e646" 100 | dependencies = [ 101 | "os_str_bytes", 102 | ] 103 | 104 | [[package]] 105 | name = "clipboard" 106 | version = "0.5.0" 107 | source = "registry+https://github.com/rust-lang/crates.io-index" 108 | checksum = "25a904646c0340239dcf7c51677b33928bf24fdf424b79a57909c0109075b2e7" 109 | dependencies = [ 110 | "clipboard-win 2.2.0", 111 | "objc", 112 | "objc-foundation", 113 | "objc_id", 114 | "x11-clipboard", 115 | ] 116 | 117 | [[package]] 118 | name = "clipboard-win" 119 | version = "2.2.0" 120 | source = "registry+https://github.com/rust-lang/crates.io-index" 121 | checksum = "e3a093d6fed558e5fe24c3dfc85a68bb68f1c824f440d3ba5aca189e2998786b" 122 | dependencies = [ 123 | "winapi", 124 | ] 125 | 126 | [[package]] 127 | name = "clipboard-win" 128 | version = "4.2.1" 129 | source = "registry+https://github.com/rust-lang/crates.io-index" 130 | checksum = "4e4ea1881992efc993e4dc50a324cdbd03216e41bdc8385720ff47efc9bd2ca8" 131 | dependencies = [ 132 | "error-code", 133 | "str-buf", 134 | "winapi", 135 | ] 136 | 137 | [[package]] 138 | name = "dirs-next" 139 | version = "2.0.0" 140 | source = "registry+https://github.com/rust-lang/crates.io-index" 141 | checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1" 142 | dependencies = [ 143 | "cfg-if 1.0.0", 144 | "dirs-sys-next", 145 | ] 146 | 147 | [[package]] 148 | name = "dirs-sys-next" 149 | version = "0.1.2" 150 | source = "registry+https://github.com/rust-lang/crates.io-index" 151 | checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" 152 | dependencies = [ 153 | "libc", 154 | "redox_users", 155 | "winapi", 156 | ] 157 | 158 | [[package]] 159 | name = "endian-type" 160 | version = "0.1.2" 161 | source = "registry+https://github.com/rust-lang/crates.io-index" 162 | checksum = "c34f04666d835ff5d62e058c3995147c06f42fe86ff053337632bca83e42702d" 163 | 164 | [[package]] 165 | name = "error-code" 166 | version = "2.3.0" 167 | source = "registry+https://github.com/rust-lang/crates.io-index" 168 | checksum = "b5115567ac25674e0043e472be13d14e537f37ea8aa4bdc4aef0c89add1db1ff" 169 | dependencies = [ 170 | "libc", 171 | "str-buf", 172 | ] 173 | 174 | [[package]] 175 | name = "fd-lock" 176 | version = "3.0.0" 177 | source = "registry+https://github.com/rust-lang/crates.io-index" 178 | checksum = "b8806dd91a06a7a403a8e596f9bfbfb34e469efbc363fc9c9713e79e26472e36" 179 | dependencies = [ 180 | "cfg-if 1.0.0", 181 | "libc", 182 | "winapi", 183 | ] 184 | 185 | [[package]] 186 | name = "fnv" 187 | version = "1.0.7" 188 | source = "registry+https://github.com/rust-lang/crates.io-index" 189 | checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" 190 | 191 | [[package]] 192 | name = "getrandom" 193 | version = "0.2.3" 194 | source = "registry+https://github.com/rust-lang/crates.io-index" 195 | checksum = "7fcd999463524c52659517fe2cea98493cfe485d10565e7b0fb07dbba7ad2753" 196 | dependencies = [ 197 | "cfg-if 1.0.0", 198 | "libc", 199 | "wasi", 200 | ] 201 | 202 | [[package]] 203 | name = "heck" 204 | version = "0.4.0" 205 | source = "registry+https://github.com/rust-lang/crates.io-index" 206 | checksum = "2540771e65fc8cb83cd6e8a237f70c319bd5c29f78ed1084ba5d50eeac86f7f9" 207 | 208 | [[package]] 209 | name = "hermit-abi" 210 | version = "0.1.18" 211 | source = "registry+https://github.com/rust-lang/crates.io-index" 212 | checksum = "322f4de77956e22ed0e5032c359a0f1273f1f7f0d79bfa3b8ffbc730d7fbcc5c" 213 | dependencies = [ 214 | "libc", 215 | ] 216 | 217 | [[package]] 218 | name = "indoc" 219 | version = "1.0.4" 220 | source = "registry+https://github.com/rust-lang/crates.io-index" 221 | checksum = "e7906a9fababaeacb774f72410e497a1d18de916322e33797bb2cd29baa23c9e" 222 | dependencies = [ 223 | "unindent", 224 | ] 225 | 226 | [[package]] 227 | name = "isatty" 228 | version = "0.1.9" 229 | source = "registry+https://github.com/rust-lang/crates.io-index" 230 | checksum = "e31a8281fc93ec9693494da65fbf28c0c2aa60a2eaec25dc58e2f31952e95edc" 231 | dependencies = [ 232 | "cfg-if 0.1.10", 233 | "libc", 234 | "redox_syscall 0.1.57", 235 | "winapi", 236 | ] 237 | 238 | [[package]] 239 | name = "jless" 240 | version = "0.9.0" 241 | dependencies = [ 242 | "clap", 243 | "clipboard", 244 | "indoc", 245 | "isatty", 246 | "lazy_static", 247 | "libc", 248 | "libc-stdhandle", 249 | "logos", 250 | "regex", 251 | "rustyline", 252 | "signal-hook", 253 | "termion", 254 | "unicode-segmentation", 255 | "unicode-width", 256 | "yaml-rust", 257 | ] 258 | 259 | [[package]] 260 | name = "lazy_static" 261 | version = "1.4.0" 262 | source = "registry+https://github.com/rust-lang/crates.io-index" 263 | checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" 264 | 265 | [[package]] 266 | name = "libc" 267 | version = "0.2.99" 268 | source = "registry+https://github.com/rust-lang/crates.io-index" 269 | checksum = "a7f823d141fe0a24df1e23b4af4e3c7ba9e5966ec514ea068c93024aa7deb765" 270 | 271 | [[package]] 272 | name = "libc-stdhandle" 273 | version = "0.1.0" 274 | source = "registry+https://github.com/rust-lang/crates.io-index" 275 | checksum = "6dac2473dc28934c5e0b82250dab231c9d3b94160d91fe9ff483323b05797551" 276 | dependencies = [ 277 | "cc", 278 | "libc", 279 | ] 280 | 281 | [[package]] 282 | name = "linked-hash-map" 283 | version = "0.5.4" 284 | source = "registry+https://github.com/rust-lang/crates.io-index" 285 | checksum = "7fb9b38af92608140b86b693604b9ffcc5824240a484d1ecd4795bacb2fe88f3" 286 | 287 | [[package]] 288 | name = "log" 289 | version = "0.4.14" 290 | source = "registry+https://github.com/rust-lang/crates.io-index" 291 | checksum = "51b9bbe6c47d51fc3e1a9b945965946b4c44142ab8792c50835a980d362c2710" 292 | dependencies = [ 293 | "cfg-if 1.0.0", 294 | ] 295 | 296 | [[package]] 297 | name = "logos" 298 | version = "0.12.0" 299 | source = "registry+https://github.com/rust-lang/crates.io-index" 300 | checksum = "427e2abca5be13136da9afdbf874e6b34ad9001dd70f2b103b083a85daa7b345" 301 | dependencies = [ 302 | "logos-derive", 303 | ] 304 | 305 | [[package]] 306 | name = "logos-derive" 307 | version = "0.12.0" 308 | source = "registry+https://github.com/rust-lang/crates.io-index" 309 | checksum = "56a7d287fd2ac3f75b11f19a1c8a874a7d55744bd91f7a1b3e7cf87d4343c36d" 310 | dependencies = [ 311 | "beef", 312 | "fnv", 313 | "proc-macro2", 314 | "quote", 315 | "regex-syntax", 316 | "syn", 317 | "utf8-ranges", 318 | ] 319 | 320 | [[package]] 321 | name = "malloc_buf" 322 | version = "0.0.6" 323 | source = "registry+https://github.com/rust-lang/crates.io-index" 324 | checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" 325 | dependencies = [ 326 | "libc", 327 | ] 328 | 329 | [[package]] 330 | name = "memchr" 331 | version = "2.4.1" 332 | source = "registry+https://github.com/rust-lang/crates.io-index" 333 | checksum = "308cc39be01b73d0d18f82a0e7b2a3df85245f84af96fdddc5d202d27e47b86a" 334 | 335 | [[package]] 336 | name = "memoffset" 337 | version = "0.6.4" 338 | source = "registry+https://github.com/rust-lang/crates.io-index" 339 | checksum = "59accc507f1338036a0477ef61afdae33cde60840f4dfe481319ce3ad116ddf9" 340 | dependencies = [ 341 | "autocfg", 342 | ] 343 | 344 | [[package]] 345 | name = "nibble_vec" 346 | version = "0.1.0" 347 | source = "registry+https://github.com/rust-lang/crates.io-index" 348 | checksum = "77a5d83df9f36fe23f0c3648c6bbb8b0298bb5f1939c8f2704431371f4b84d43" 349 | dependencies = [ 350 | "smallvec", 351 | ] 352 | 353 | [[package]] 354 | name = "nix" 355 | version = "0.22.1" 356 | source = "registry+https://github.com/rust-lang/crates.io-index" 357 | checksum = "e7555d6c7164cc913be1ce7f95cbecdabda61eb2ccd89008524af306fb7f5031" 358 | dependencies = [ 359 | "bitflags", 360 | "cc", 361 | "cfg-if 1.0.0", 362 | "libc", 363 | "memoffset", 364 | ] 365 | 366 | [[package]] 367 | name = "numtoa" 368 | version = "0.1.0" 369 | source = "registry+https://github.com/rust-lang/crates.io-index" 370 | checksum = "b8f8bdf33df195859076e54ab11ee78a1b208382d3a26ec40d142ffc1ecc49ef" 371 | 372 | [[package]] 373 | name = "objc" 374 | version = "0.2.7" 375 | source = "registry+https://github.com/rust-lang/crates.io-index" 376 | checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" 377 | dependencies = [ 378 | "malloc_buf", 379 | ] 380 | 381 | [[package]] 382 | name = "objc-foundation" 383 | version = "0.1.1" 384 | source = "registry+https://github.com/rust-lang/crates.io-index" 385 | checksum = "1add1b659e36c9607c7aab864a76c7a4c2760cd0cd2e120f3fb8b952c7e22bf9" 386 | dependencies = [ 387 | "block", 388 | "objc", 389 | "objc_id", 390 | ] 391 | 392 | [[package]] 393 | name = "objc_id" 394 | version = "0.1.1" 395 | source = "registry+https://github.com/rust-lang/crates.io-index" 396 | checksum = "c92d4ddb4bd7b50d730c215ff871754d0da6b2178849f8a2a2ab69712d0c073b" 397 | dependencies = [ 398 | "objc", 399 | ] 400 | 401 | [[package]] 402 | name = "once_cell" 403 | version = "1.18.0" 404 | source = "registry+https://github.com/rust-lang/crates.io-index" 405 | checksum = "dd8b5dd2ae5ed71462c540258bedcb51965123ad7e7ccf4b9a8cafaa4a63576d" 406 | 407 | [[package]] 408 | name = "os_str_bytes" 409 | version = "6.0.0" 410 | source = "registry+https://github.com/rust-lang/crates.io-index" 411 | checksum = "8e22443d1643a904602595ba1cd8f7d896afe56d26712531c5ff73a15b2fbf64" 412 | 413 | [[package]] 414 | name = "proc-macro-error" 415 | version = "1.0.4" 416 | source = "registry+https://github.com/rust-lang/crates.io-index" 417 | checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" 418 | dependencies = [ 419 | "proc-macro-error-attr", 420 | "proc-macro2", 421 | "quote", 422 | "syn", 423 | "version_check", 424 | ] 425 | 426 | [[package]] 427 | name = "proc-macro-error-attr" 428 | version = "1.0.4" 429 | source = "registry+https://github.com/rust-lang/crates.io-index" 430 | checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" 431 | dependencies = [ 432 | "proc-macro2", 433 | "quote", 434 | "version_check", 435 | ] 436 | 437 | [[package]] 438 | name = "proc-macro2" 439 | version = "1.0.63" 440 | source = "registry+https://github.com/rust-lang/crates.io-index" 441 | checksum = "7b368fba921b0dce7e60f5e04ec15e565b3303972b42bcfde1d0713b881959eb" 442 | dependencies = [ 443 | "unicode-ident", 444 | ] 445 | 446 | [[package]] 447 | name = "quote" 448 | version = "1.0.9" 449 | source = "registry+https://github.com/rust-lang/crates.io-index" 450 | checksum = "c3d0b9745dc2debf507c8422de05d7226cc1f0644216dfdfead988f9b1ab32a7" 451 | dependencies = [ 452 | "proc-macro2", 453 | ] 454 | 455 | [[package]] 456 | name = "radix_trie" 457 | version = "0.2.1" 458 | source = "registry+https://github.com/rust-lang/crates.io-index" 459 | checksum = "c069c179fcdc6a2fe24d8d18305cf085fdbd4f922c041943e203685d6a1c58fd" 460 | dependencies = [ 461 | "endian-type", 462 | "nibble_vec", 463 | ] 464 | 465 | [[package]] 466 | name = "redox_syscall" 467 | version = "0.1.57" 468 | source = "registry+https://github.com/rust-lang/crates.io-index" 469 | checksum = "41cc0f7e4d5d4544e8861606a285bb08d3e70712ccc7d2b84d7c0ccfaf4b05ce" 470 | 471 | [[package]] 472 | name = "redox_syscall" 473 | version = "0.2.6" 474 | source = "registry+https://github.com/rust-lang/crates.io-index" 475 | checksum = "8270314b5ccceb518e7e578952f0b72b88222d02e8f77f5ecf7abbb673539041" 476 | dependencies = [ 477 | "bitflags", 478 | ] 479 | 480 | [[package]] 481 | name = "redox_termios" 482 | version = "0.1.2" 483 | source = "registry+https://github.com/rust-lang/crates.io-index" 484 | checksum = "8440d8acb4fd3d277125b4bd01a6f38aee8d814b3b5fc09b3f2b825d37d3fe8f" 485 | dependencies = [ 486 | "redox_syscall 0.2.6", 487 | ] 488 | 489 | [[package]] 490 | name = "redox_users" 491 | version = "0.4.0" 492 | source = "registry+https://github.com/rust-lang/crates.io-index" 493 | checksum = "528532f3d801c87aec9def2add9ca802fe569e44a544afe633765267840abe64" 494 | dependencies = [ 495 | "getrandom", 496 | "redox_syscall 0.2.6", 497 | ] 498 | 499 | [[package]] 500 | name = "regex" 501 | version = "1.5.5" 502 | source = "registry+https://github.com/rust-lang/crates.io-index" 503 | checksum = "1a11647b6b25ff05a515cb92c365cec08801e83423a235b51e231e1808747286" 504 | dependencies = [ 505 | "aho-corasick", 506 | "memchr", 507 | "regex-syntax", 508 | ] 509 | 510 | [[package]] 511 | name = "regex-syntax" 512 | version = "0.6.25" 513 | source = "registry+https://github.com/rust-lang/crates.io-index" 514 | checksum = "f497285884f3fcff424ffc933e56d7cbca511def0c9831a7f9b5f6153e3cc89b" 515 | 516 | [[package]] 517 | name = "rustyline" 518 | version = "9.0.0" 519 | source = "registry+https://github.com/rust-lang/crates.io-index" 520 | checksum = "790487c3881a63489ae77126f57048b42d62d3b2bafbf37453ea19eedb6340d6" 521 | dependencies = [ 522 | "bitflags", 523 | "cfg-if 1.0.0", 524 | "clipboard-win 4.2.1", 525 | "dirs-next", 526 | "fd-lock", 527 | "libc", 528 | "log", 529 | "memchr", 530 | "nix", 531 | "radix_trie", 532 | "scopeguard", 533 | "smallvec", 534 | "unicode-segmentation", 535 | "unicode-width", 536 | "utf8parse", 537 | "winapi", 538 | ] 539 | 540 | [[package]] 541 | name = "scopeguard" 542 | version = "1.1.0" 543 | source = "registry+https://github.com/rust-lang/crates.io-index" 544 | checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd" 545 | 546 | [[package]] 547 | name = "signal-hook" 548 | version = "0.3.8" 549 | source = "registry+https://github.com/rust-lang/crates.io-index" 550 | checksum = "ef33d6d0cd06e0840fba9985aab098c147e67e05cee14d412d3345ed14ff30ac" 551 | dependencies = [ 552 | "libc", 553 | "signal-hook-registry", 554 | ] 555 | 556 | [[package]] 557 | name = "signal-hook-registry" 558 | version = "1.3.0" 559 | source = "registry+https://github.com/rust-lang/crates.io-index" 560 | checksum = "16f1d0fef1604ba8f7a073c7e701f213e056707210e9020af4528e0101ce11a6" 561 | dependencies = [ 562 | "libc", 563 | ] 564 | 565 | [[package]] 566 | name = "smallvec" 567 | version = "1.6.1" 568 | source = "registry+https://github.com/rust-lang/crates.io-index" 569 | checksum = "fe0f37c9e8f3c5a4a66ad655a93c74daac4ad00c441533bf5c6e7990bb42604e" 570 | 571 | [[package]] 572 | name = "str-buf" 573 | version = "1.0.5" 574 | source = "registry+https://github.com/rust-lang/crates.io-index" 575 | checksum = "d44a3643b4ff9caf57abcee9c2c621d6c03d9135e0d8b589bd9afb5992cb176a" 576 | 577 | [[package]] 578 | name = "strsim" 579 | version = "0.10.0" 580 | source = "registry+https://github.com/rust-lang/crates.io-index" 581 | checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" 582 | 583 | [[package]] 584 | name = "syn" 585 | version = "1.0.86" 586 | source = "registry+https://github.com/rust-lang/crates.io-index" 587 | checksum = "8a65b3f4ffa0092e9887669db0eae07941f023991ab58ea44da8fe8e2d511c6b" 588 | dependencies = [ 589 | "proc-macro2", 590 | "quote", 591 | "unicode-xid", 592 | ] 593 | 594 | [[package]] 595 | name = "termcolor" 596 | version = "1.1.2" 597 | source = "registry+https://github.com/rust-lang/crates.io-index" 598 | checksum = "2dfed899f0eb03f32ee8c6a0aabdb8a7949659e3466561fc0adf54e26d88c5f4" 599 | dependencies = [ 600 | "winapi-util", 601 | ] 602 | 603 | [[package]] 604 | name = "termion" 605 | version = "1.5.6" 606 | source = "registry+https://github.com/rust-lang/crates.io-index" 607 | checksum = "077185e2eac69c3f8379a4298e1e07cd36beb962290d4a51199acf0fdc10607e" 608 | dependencies = [ 609 | "libc", 610 | "numtoa", 611 | "redox_syscall 0.2.6", 612 | "redox_termios", 613 | ] 614 | 615 | [[package]] 616 | name = "unicode-ident" 617 | version = "1.0.10" 618 | source = "registry+https://github.com/rust-lang/crates.io-index" 619 | checksum = "22049a19f4a68748a168c0fc439f9516686aa045927ff767eca0a85101fb6e73" 620 | 621 | [[package]] 622 | name = "unicode-segmentation" 623 | version = "1.7.1" 624 | source = "registry+https://github.com/rust-lang/crates.io-index" 625 | checksum = "bb0d2e7be6ae3a5fa87eed5fb451aff96f2573d2694942e40543ae0bbe19c796" 626 | 627 | [[package]] 628 | name = "unicode-width" 629 | version = "0.1.8" 630 | source = "registry+https://github.com/rust-lang/crates.io-index" 631 | checksum = "9337591893a19b88d8d87f2cec1e73fad5cdfd10e5a6f349f498ad6ea2ffb1e3" 632 | 633 | [[package]] 634 | name = "unicode-xid" 635 | version = "0.2.1" 636 | source = "registry+https://github.com/rust-lang/crates.io-index" 637 | checksum = "f7fe0bb3479651439c9112f72b6c505038574c9fbb575ed1bf3b797fa39dd564" 638 | 639 | [[package]] 640 | name = "unindent" 641 | version = "0.1.8" 642 | source = "registry+https://github.com/rust-lang/crates.io-index" 643 | checksum = "514672a55d7380da379785a4d70ca8386c8883ff7eaae877be4d2081cebe73d8" 644 | 645 | [[package]] 646 | name = "utf8-ranges" 647 | version = "1.0.4" 648 | source = "registry+https://github.com/rust-lang/crates.io-index" 649 | checksum = "b4ae116fef2b7fea257ed6440d3cfcff7f190865f170cdad00bb6465bf18ecba" 650 | 651 | [[package]] 652 | name = "utf8parse" 653 | version = "0.2.0" 654 | source = "registry+https://github.com/rust-lang/crates.io-index" 655 | checksum = "936e4b492acfd135421d8dca4b1aa80a7bfc26e702ef3af710e0752684df5372" 656 | 657 | [[package]] 658 | name = "version_check" 659 | version = "0.9.3" 660 | source = "registry+https://github.com/rust-lang/crates.io-index" 661 | checksum = "5fecdca9a5291cc2b8dcf7dc02453fee791a280f3743cb0905f8822ae463b3fe" 662 | 663 | [[package]] 664 | name = "wasi" 665 | version = "0.10.2+wasi-snapshot-preview1" 666 | source = "registry+https://github.com/rust-lang/crates.io-index" 667 | checksum = "fd6fbd9a79829dd1ad0cc20627bf1ed606756a7f77edff7b66b7064f9cb327c6" 668 | 669 | [[package]] 670 | name = "winapi" 671 | version = "0.3.9" 672 | source = "registry+https://github.com/rust-lang/crates.io-index" 673 | checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" 674 | dependencies = [ 675 | "winapi-i686-pc-windows-gnu", 676 | "winapi-x86_64-pc-windows-gnu", 677 | ] 678 | 679 | [[package]] 680 | name = "winapi-i686-pc-windows-gnu" 681 | version = "0.4.0" 682 | source = "registry+https://github.com/rust-lang/crates.io-index" 683 | checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" 684 | 685 | [[package]] 686 | name = "winapi-util" 687 | version = "0.1.5" 688 | source = "registry+https://github.com/rust-lang/crates.io-index" 689 | checksum = "70ec6ce85bb158151cae5e5c87f95a8e97d2c0c4b001223f33a334e3ce5de178" 690 | dependencies = [ 691 | "winapi", 692 | ] 693 | 694 | [[package]] 695 | name = "winapi-x86_64-pc-windows-gnu" 696 | version = "0.4.0" 697 | source = "registry+https://github.com/rust-lang/crates.io-index" 698 | checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" 699 | 700 | [[package]] 701 | name = "x11-clipboard" 702 | version = "0.3.3" 703 | source = "registry+https://github.com/rust-lang/crates.io-index" 704 | checksum = "89bd49c06c9eb5d98e6ba6536cf64ac9f7ee3a009b2f53996d405b3944f6bcea" 705 | dependencies = [ 706 | "xcb", 707 | ] 708 | 709 | [[package]] 710 | name = "xcb" 711 | version = "0.8.2" 712 | source = "registry+https://github.com/rust-lang/crates.io-index" 713 | checksum = "5e917a3f24142e9ff8be2414e36c649d47d6cc2ba81f16201cdef96e533e02de" 714 | dependencies = [ 715 | "libc", 716 | "log", 717 | ] 718 | 719 | [[package]] 720 | name = "yaml-rust" 721 | version = "0.4.5" 722 | source = "registry+https://github.com/rust-lang/crates.io-index" 723 | checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85" 724 | dependencies = [ 725 | "linked-hash-map", 726 | ] 727 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "jless" 3 | version = "0.9.0" 4 | authors = ["Paul Julius Martinez "] 5 | license = "MIT" 6 | description = "A command-line JSON viewer" 7 | keywords = ["cli", "json"] 8 | categories = ["command-line-utilities"] 9 | repository = "https://github.com/PaulJuliusMartinez/jless" 10 | homepage = "https://jless.io" 11 | documentation = "https://jless.io/user-guide.html" 12 | edition = "2018" 13 | rust-version = "1.67" 14 | 15 | [features] 16 | default = [] 17 | sexp = [] 18 | 19 | [dependencies] 20 | logos = "0.12.0" 21 | unicode-width = "0.1.5" 22 | unicode-segmentation = "1.7.1" 23 | rustyline = "9.0.0" 24 | regex = "1.5" 25 | lazy_static = "1.4.0" 26 | termion = "1.5.6" 27 | signal-hook = "0.3.8" 28 | libc = "0.2" 29 | clap = { version = "4.0", features = ["derive"] } 30 | isatty = "0.1" 31 | libc-stdhandle = "0.1.0" 32 | yaml-rust = "0.4" 33 | clipboard = "0.5" 34 | 35 | [dev-dependencies] 36 | indoc = "1.0" 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2021 Paul Julius Martinez 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![jless logo and mascot](https://raw.githubusercontent.com/PaulJuliusMartinez/jless/master/logo/text-logo-with-mascot.svg) 2 | 3 | [`jless`](https://jless.io) is a command-line JSON viewer. Use it as a 4 | replacement for whatever combination of `less`, `jq`, `cat` and your 5 | editor you currently use for viewing JSON files. It is written in Rust 6 | and can be installed as a single standalone binary. 7 | 8 | [![ci](https://github.com/PaulJuliusMartinez/jless/actions/workflows/ci.yml/badge.svg?branch=master&event=push)](https://github.com/PaulJuliusMartinez/jless/actions/workflows/ci.yml) 9 | 10 | ### Features 11 | 12 | - Clean syntax highlighted display of JSON data, omitting quotes around 13 | object keys, closing object and array delimiters, and trailing commas. 14 | - Expand and collapse objects and arrays so you can see both the high- 15 | and low-level structure of the data. 16 | - A wealth of vim-inspired movement commands for efficiently moving 17 | around and viewing data. 18 | - Full regex-based search for finding exactly the data you're looking 19 | for. 20 | 21 | `jless` currently supports macOS and Linux. Windows support is planned. 22 | 23 | ## Installation 24 | 25 | You can install `jless` using various package managers: 26 | 27 | | Operating System / Package Manager | Command | 28 | | ---------------------------------- | ------- | 29 | | macOS - [HomeBrew](https://formulae.brew.sh/formula/jless) | `brew install jless` | 30 | | macOS - [MacPorts](https://ports.macports.org/port/jless/) | `sudo port install jless` | 31 | | Linux - [HomeBrew](https://formulae.brew.sh/formula/jless) | `brew install jless` | 32 | | [Arch Linux](https://archlinux.org/packages/extra/x86_64/jless/) | `pacman -S jless` | 33 | | [Void Linux](https://github.com/void-linux/void-packages/tree/master/srcpkgs/jless) | `sudo xbps-install jless` | 34 | | [NetBSD](https://pkgsrc.se/textproc/jless/) | `pkgin install jless` | 35 | | [FreeBSD](https://freshports.org/textproc/jless/) | `pkg install jless` | 36 | | From source (Requires [Rust toolchain](https://www.rust-lang.org/tools/install)) | `cargo install jless` | 37 | 38 | The [releases](https://github.com/PaulJuliusMartinez/jless/releases) 39 | page also contains links to binaries for various architectures. 40 | 41 | ## Dependencies 42 | 43 | On Linux systems, X11 libraries are needed to build clipboard access if 44 | building from source. On Ubuntu you can install these using: 45 | 46 | ``` 47 | sudo apt-get install libxcb1-dev libxcb-render0-dev libxcb-shape0-dev libxcb-xfixes0-dev 48 | ``` 49 | 50 | ## Website 51 | 52 | [jless.io](https://jless.io) is the official website for `jless`. Code 53 | for the website is contained separately on the 54 | [`website`](https://github.com/PaulJuliusMartinez/jless/tree/website) branch. 55 | 56 | ## Logo 57 | 58 | The mascot of the `jless` project is Jules the jellyfish. 59 | 60 | jless mascot 61 | 62 | Art for Jules was created by 63 | [`annatgraphics`](https://www.fiverr.com/annatgraphics). 64 | 65 | ## License 66 | 67 | `jless` is released under the [MIT License](https://github.com/PaulJuliusMartinez/jless/blob/master/LICENSE). 68 | -------------------------------------------------------------------------------- /RELEASE_CHECKLIST.md: -------------------------------------------------------------------------------- 1 | ## Release Checklist 2 | 3 | - `VERSION=` (including a `v` at the start) 4 | - Update version in [`Cargo.toml`](./Cargo.toml). 5 | - Run `cargo build` to update [`Cargo.lock`](./Cargo.lock). 6 | - Add changes since last release to [`CHANGELOG.md`](./CHANGELOG.md). (You 7 | should do this with every commit!) 8 | - Update the top of the CHANGELOG to say the new version number with 9 | the release date, then start a new section for `main` 10 | - Commit all changes with commit message: `vX.Y.Z Release` 11 | - Tag commit and push it to GitHub: `git tag $VERSION && git push origin $VERSION` 12 | - Publish new version to crates.io: `cargo publish` 13 | - Generate new binaries: 14 | - macOS: 15 | - `cargo build --release` 16 | - `cd target/release` 17 | - `zip -r -X jless-$VERSION-x86_64-apple-darwin.zip jless` 18 | - Linux: 19 | - Make sure you can cross-compile for Linux: 20 | - `brew tap SergioBenitez/osxct` 21 | - `brew install x86_64-unknown-linux-gnu` 22 | - `CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER=x86_64-unknown-linux-gnu-gcc cargo build --release --target=x86_64-unknown-linux-gnu` 23 | - `cd target/x86_64-unknown-linux-gnu/release` 24 | - `zip -r -X jless-$VERSION-x86_64-unknown-linux-gnu.zip target/x86_64-unknown-linux-gnu/release/jless` 25 | - Create GitHub release 26 | - Click "Create new release" 27 | - Select tag 28 | - Copy stuff from `CHANGELOG.md` to description 29 | - Attach binaries generated above 30 | - Update the [`website` branch](https://github.com/PaulJuliusMartinez/jless/tree/website) 31 | - Update [`releases_page.rb`](https://github.com/PaulJuliusMartinez/jless/blob/website/releases_page.rb) with the new release 32 | - Update [`user_guide_page.rb`](https://github.com/PaulJuliusMartinez/jless/blob/website/user_guide_page.rb) with any new commands 33 | -------------------------------------------------------------------------------- /SEARCH.md: -------------------------------------------------------------------------------- 1 | # Search 2 | 3 | ## vim search 4 | 5 | - Type /term, hit enter, jumps to it 6 | - Bottom of screen says [M/N] indicating matches, then 7 | specifies "W [1/N]" when wrapping 8 | 9 | 10 | - With default settings: 11 | - Cursor disappears when you hit '/' 12 | - Matches get highlighted as you type 13 | - Next match is white 14 | - Other matches are yellow 15 | - Matches stay highlighted 16 | 17 | - There is a setting (`redrawtime`) that specifies max time 18 | spent finding matches 19 | 20 | - `hlsearch` indicates whether results are highlighted when done searching 21 | - `incsearch` indicates whether results are highlighted while typing term 22 | 23 | ## Our initial implementation 24 | 25 | - No highlighting as you type or after searching 26 | - Need to store last search term 27 | - Also computed regex 28 | - Need to store list of matches 29 | - We will find ALL matches at first 30 | - Store them in a single vector? 31 | 32 | - Do we need to store a index of our last jump anywhere? 33 | - Multiple matches in a single line 34 | - Store last match jumped to 35 | 36 | ## Desired behavior 37 | 38 | - Incremental + highlight search until a non-search key is pressed 39 | (either `n`/`N` or `*`/`#`) 40 | - Need to store whether actively searching still 41 | - Match is highlighted in string values 42 | - How are multiline searches highlighted? 43 | - How are keys highlighted? 44 | - Need to handle true search vs. key search slightly differently 45 | 46 | 47 | ### Collapsed Containers 48 | 49 | ``` 50 | { 51 | a: "apple", 52 | b: [ 53 | "cherry", 54 | "date", 55 | ], 56 | c: "cherry", 57 | } 58 | 59 | { 60 | a: "apple", 61 | b: ["cherry", "date"], 62 | c: "cherry", 63 | } 64 | ``` 65 | 66 | When a match is found in a collapsed container (e.g., searching for 67 | `cherry` above while `b` is highlighted), we will jump to that key/row, 68 | and display a message "There are N matches inside this container", then 69 | next search will continue after the collapsed container. 70 | 71 | - Maybe there's a setting to automatically expand containers while 72 | searching. 73 | 74 | ## Search State 75 | 76 | ``` 77 | struct SearchState { 78 | mode: enum { Key, Free }, 79 | direction: enum { Down, Up }, 80 | search_term: String, 81 | compiled_regex: Regex, 82 | matches: Vec>, 83 | last_jump: usize, 84 | actively_searching: bool, 85 | } 86 | ``` 87 | 88 | ## Search Inputs 89 | 90 | `/` Start freeform search 91 | `?` Start reverse freeform search 92 | `*` Start object key search 93 | `#` Start reverse object key search 94 | `n` Go to next match 95 | `N` Go to previous match 96 | 97 | 98 | When starting search => 99 | - Create SearchState with actively searching `false` 100 | - Need mode & direction naturally 101 | - Need search term 102 | - Need json text 103 | - Then basically do a "go to next match" 104 | 105 | ## Messaging: 106 | 107 | - Show search term in bottom while "actively searching" 108 | - After each jump show `W? [M/N]` 109 | - On collapsed containers display how many matches are inside 110 | - "No matches found" 111 | - Bad regex 112 | 113 | 114 | ## Tricky stuff 115 | 116 | - Updating immediate search state appropriately 117 | -------------------------------------------------------------------------------- /dev-tmux: -------------------------------------------------------------------------------- 1 | #! /bin/sh 2 | tmux new-session -d -s jless 3 | tmux split-window -t jless -h 4 | tmux split-window -t jless -v 5 | tmux attach-session -t jless 6 | -------------------------------------------------------------------------------- /examples/escaped_strings_for_yanking.json: -------------------------------------------------------------------------------- 1 | { 2 | "description": "This file contains string values with escaped characters to test yanking escaping values.", 3 | "tests": { 4 | "simple": "abc", 5 | "escaped_slashes": "abc \\ / \/ def", 6 | "escaped_quote": "middle \"words in\" quotes", 7 | "escaped_spaces": "tab:\t; newline:\n; carriage return:\r; end", 8 | "escaped_backspace": "easy as 1, 2, 4\b3", 9 | "ascii_control_characters": "NUL \\u0000, ESC \\u001B, DEL \\u007F", 10 | "unicode_control_characters": "0x80: \\u0080, 0x90: \\u0090, 0x9F: \\u009F", 11 | "unicode_euro_sign": "€ \u20AC", 12 | "unicode_euro_sign_casing": "€ \u20ac \u20aC", 13 | "unicode_supplemental_plane": "𐐷 \uD801\uDC37", 14 | "unicode_unmatched_high_surrogate": "no low surrogate: \u0024 \uD801", 15 | "unicode_unexpected_low_surrogate": "no high surrogate: \u0024 \uDC37", 16 | "control_characters_red_text": "\u001B[0;31mRED TEXT\u001B[0m" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /logo/github-social-thumbnail.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PaulJuliusMartinez/jless/e6cdef719c7319020391d6bbf838ab272ce44cf0/logo/github-social-thumbnail.png -------------------------------------------------------------------------------- /src/highlighting.rs: -------------------------------------------------------------------------------- 1 | use std::fmt; 2 | use std::iter::Peekable; 3 | use std::ops::Range; 4 | 5 | use crate::search::MatchRangeIter; 6 | use crate::terminal; 7 | use crate::terminal::{Style, Terminal}; 8 | use crate::truncatedstrview::TruncatedStrView; 9 | 10 | // This module is responsible for highlighting text in the 11 | // appropriate colors when we print it out. 12 | // 13 | // We use different colors for different JSON value types, 14 | // as well as different shades of gray. 15 | // 16 | // We searching for text, we highlight matches in yellow. 17 | // In certain cases, when highlighted matches are also focused, 18 | // we invert the normal colors of the terminal (to handle 19 | // both light and dark color schemes). 20 | // 21 | // 22 | // These are all the different things that we print out that 23 | // may require special formatting: 24 | // 25 | // - Literal Values: 26 | // - null 27 | // - boolean 28 | // - number 29 | // - string 30 | // - empty objects and arrays 31 | // - Object Keys 32 | // - The ": " between an object key and its value 33 | // - Array indexes in data mode (e.g., "[123]") 34 | // - The ": " between an array index and the value 35 | // - Note that unlike the ": " between object keys and 36 | // values, these do not actually appear in the source 37 | // JSON and cannot be part of a search match 38 | // - Commas after object and array elements 39 | // - Open and close braces and brackets ("{}[]") 40 | // - Container previews 41 | 42 | // Thing | Default Style | Focused Style | Match | Focused/Current Match 43 | // ----------------+-----------------+-----------------+----------------+------------------------ 44 | // null | Gray | X | Yellow/Default | Inverted 45 | // boolean | Yellow | X | Yellow/Default | Inverted 46 | // number | Magenta | X | Yellow/Default | Inverted 47 | // string | Green | X | Yellow/Default | Inverted 48 | // empty obj/arr | Default | X | Yellow/Default | Inverted 49 | // 50 | // ^ Object values can't be focused 51 | // 52 | // ": " and "," | Default | Default | Yellow/Default | Inverted 53 | // 54 | // Object Labels | Blue | Inverted/Blue | Yellow/Default | Inverted 55 | // + Bold 56 | // 57 | // Array Labels | Gray | Default + Bold | X | X 58 | // 59 | // Container | Default | Bold | Yellow/Default | Inverted + Bold 60 | // Delimiters 61 | // 62 | // Container | Gray | Default | Inverted Gray | Inverted 63 | // Previews 64 | 65 | pub const DEFAULT_STYLE: Style = Style::default(); 66 | 67 | pub const BOLD_STYLE: Style = Style { 68 | bold: true, 69 | ..Style::default() 70 | }; 71 | 72 | pub const BOLD_INVERTED_STYLE: Style = Style { 73 | inverted: true, 74 | bold: true, 75 | ..Style::default() 76 | }; 77 | 78 | pub const GRAY_INVERTED_STYLE: Style = Style { 79 | fg: terminal::LIGHT_BLACK, 80 | inverted: true, 81 | ..Style::default() 82 | }; 83 | 84 | pub const SEARCH_MATCH_HIGHLIGHTED: Style = Style { 85 | fg: terminal::YELLOW, 86 | inverted: true, 87 | ..Style::default() 88 | }; 89 | 90 | pub const DIMMED_STYLE: Style = Style { 91 | dimmed: true, 92 | ..Style::default() 93 | }; 94 | 95 | pub const CURRENT_LINE_NUMBER: Style = Style { 96 | fg: terminal::YELLOW, 97 | ..Style::default() 98 | }; 99 | 100 | pub const PREVIEW_STYLES: (&Style, &Style) = (&DIMMED_STYLE, &GRAY_INVERTED_STYLE); 101 | 102 | pub const BLUE_STYLE: Style = Style { 103 | fg: terminal::LIGHT_BLUE, 104 | ..Style::default() 105 | }; 106 | 107 | pub const INVERTED_BOLD_BLUE_STYLE: Style = Style { 108 | bg: terminal::BLUE, 109 | inverted: true, 110 | bold: true, 111 | ..Style::default() 112 | }; 113 | 114 | #[allow(clippy::too_many_arguments)] 115 | pub fn highlight_truncated_str_view( 116 | out: &mut dyn Terminal, 117 | mut s: &str, 118 | str_view: &TruncatedStrView, 119 | mut str_range_start: Option, 120 | style: &Style, 121 | highlight_style: &Style, 122 | matches_iter: &mut Option<&mut Peekable>>, 123 | focused_search_match: &Range, 124 | ) -> fmt::Result { 125 | let mut leading_ellipsis = false; 126 | let mut replacement_character = false; 127 | let mut trailing_ellipsis = false; 128 | 129 | if let Some(tr) = str_view.range { 130 | leading_ellipsis = tr.print_leading_ellipsis(); 131 | replacement_character = tr.showing_replacement_character; 132 | trailing_ellipsis = tr.print_trailing_ellipsis(s); 133 | s = &s[tr.start..tr.end]; 134 | str_range_start = str_range_start.map(|start| start + tr.start); 135 | } 136 | 137 | if leading_ellipsis { 138 | out.set_style(&DIMMED_STYLE)?; 139 | out.write_char('…')?; 140 | } 141 | 142 | // Print replacement character 143 | if replacement_character { 144 | out.set_style(style)?; 145 | // TODO: Technically we should figure out whether this 146 | // character's range should be highlighted, but also 147 | // maybe not bad to not highlight the replacement character; 148 | out.write_char('�')?; 149 | } 150 | 151 | // Print actual string itself 152 | highlight_matches( 153 | out, 154 | s, 155 | str_range_start, 156 | style, 157 | highlight_style, 158 | matches_iter, 159 | focused_search_match, 160 | )?; 161 | 162 | // Print trailing ellipsis 163 | if trailing_ellipsis { 164 | out.set_style(&DIMMED_STYLE)?; 165 | out.write_char('…')?; 166 | } 167 | 168 | Ok(()) 169 | } 170 | 171 | pub fn highlight_matches( 172 | out: &mut dyn Terminal, 173 | mut s: &str, 174 | str_range_start: Option, 175 | style: &Style, 176 | highlight_style: &Style, 177 | matches_iter: &mut Option<&mut Peekable>>, 178 | focused_search_match: &Range, 179 | ) -> fmt::Result { 180 | if str_range_start.is_none() { 181 | out.set_style(style)?; 182 | write!(out, "{s}")?; 183 | return Ok(()); 184 | } 185 | 186 | let mut start_index = str_range_start.unwrap(); 187 | 188 | while !s.is_empty() { 189 | // Initialize the next match to be a fake match past the end of the string. 190 | let string_end = start_index + s.len(); 191 | let mut match_start = string_end; 192 | let mut match_end = string_end; 193 | let mut match_is_focused_match = false; 194 | 195 | // Get rid of matches before the string. 196 | while let Some(range) = matches_iter.as_mut().and_then(|i| i.peek()) { 197 | if start_index < range.end { 198 | if *range == focused_search_match { 199 | match_is_focused_match = true; 200 | } 201 | 202 | match_start = range.start.clamp(start_index, string_end); 203 | match_end = range.end.clamp(start_index, string_end); 204 | break; 205 | } 206 | matches_iter.as_mut().unwrap().next(); 207 | } 208 | 209 | // Print out stuff before the start of the match, if there's any. 210 | if start_index < match_start { 211 | let print_end = match_start - start_index; 212 | out.set_style(style)?; 213 | write!(out, "{}", &s[..print_end])?; 214 | } 215 | 216 | // Highlight the matching substring. 217 | if match_start < string_end { 218 | if match_is_focused_match { 219 | out.set_style(&BOLD_INVERTED_STYLE)?; 220 | } else { 221 | out.set_style(highlight_style)?; 222 | } 223 | let print_start = match_start - start_index; 224 | let print_end = match_end - start_index; 225 | write!(out, "{}", &s[print_start..print_end])?; 226 | } 227 | 228 | // Update start_index and s 229 | s = &s[(match_end - start_index)..]; 230 | start_index = match_end; 231 | } 232 | 233 | Ok(()) 234 | } 235 | -------------------------------------------------------------------------------- /src/input.rs: -------------------------------------------------------------------------------- 1 | use signal_hook::consts::SIGWINCH; 2 | use signal_hook::low_level::pipe; 3 | use termion::event::{parse_event, Event, Key, MouseEvent}; 4 | 5 | use std::io; 6 | use std::io::{stdin, Read, Stdin}; 7 | use std::os::unix::io::AsRawFd; 8 | use std::os::unix::net::UnixStream; 9 | 10 | const POLL_INFINITE_TIMEOUT: i32 = -1; 11 | const SIGWINCH_PIPE_INDEX: usize = 0; 12 | const BUFFER_SIZE: usize = 1024; 13 | 14 | const ESCAPE: u8 = 0o33; 15 | 16 | pub fn remap_dev_tty_to_stdin() { 17 | // The readline library we use, rustyline, always gets its input from STDIN. 18 | // If jless accepts its input from STDIN, then rustyline can't accept input. 19 | // To fix this, we open up /dev/tty, and remap it to STDIN, as suggested in 20 | // this StackOverflow post: 21 | // 22 | // https://stackoverflow.com/questions/29689034/piped-stdin-and-keyboard-same-time-in-c 23 | // 24 | // rustyline may add its own fix to support reading from /dev/tty: 25 | // 26 | // https://github.com/kkawakam/rustyline/issues/599 27 | unsafe { 28 | // freopen(3) docs: https://linux.die.net/man/3/freopen 29 | let filename = std::ffi::CString::new("/dev/tty").unwrap(); 30 | let path = std::ffi::CString::new("r").unwrap(); 31 | let _ = libc::freopen(filename.as_ptr(), path.as_ptr(), libc_stdhandle::stdin()); 32 | } 33 | } 34 | 35 | pub fn get_input() -> impl Iterator> { 36 | let (sigwinch_read, sigwinch_write) = UnixStream::pair().unwrap(); 37 | // NOTE: This overrides the SIGWINCH handler registered by rustyline. 38 | // We should maybe get a reference to the existing signal handler 39 | // and call it when appropriate, but it seems to only be used to handle 40 | // line wrapping, and it seems to work fine without it. 41 | pipe::register(SIGWINCH, sigwinch_write).unwrap(); 42 | TuiInput::new(stdin(), sigwinch_read) 43 | } 44 | 45 | fn read_and_retry_on_interrupt(input: &mut Stdin, buf: &mut [u8]) -> io::Result { 46 | loop { 47 | match input.read(buf) { 48 | res @ Ok(_) => { 49 | return res; 50 | } 51 | Err(err) => { 52 | if err.kind() != io::ErrorKind::Interrupted { 53 | return Err(err); 54 | } 55 | // Otherwise just try again 56 | } 57 | } 58 | } 59 | } 60 | 61 | struct BufferedInput { 62 | input: Stdin, 63 | buffer: [u8; N], 64 | buffer_size: usize, 65 | buffer_index: usize, 66 | might_have_more_data: bool, 67 | } 68 | 69 | impl BufferedInput { 70 | fn new(input: Stdin) -> BufferedInput { 71 | BufferedInput { 72 | input, 73 | buffer: [0; N], 74 | buffer_size: 0, 75 | buffer_index: 0, 76 | might_have_more_data: false, 77 | } 78 | } 79 | 80 | fn next_u8(&mut self) -> u8 { 81 | if self.buffer_index >= self.buffer_size { 82 | panic!("No data in buffer"); 83 | } 84 | 85 | let val = self.buffer[self.buffer_index]; 86 | self.buffer_index += 1; 87 | val 88 | } 89 | 90 | fn clear(&mut self) { 91 | // Clear buffer in debug mode. 92 | if cfg!(debug_assertions) { 93 | for elem in self.buffer.iter_mut() { 94 | *elem = 0; 95 | } 96 | } 97 | 98 | self.buffer_size = 0; 99 | self.buffer_index = 0; 100 | self.might_have_more_data = false; 101 | } 102 | 103 | fn might_have_buffered_data(&self) -> bool { 104 | self.might_have_more_data || self.has_buffered_data() 105 | } 106 | 107 | fn has_buffered_data(&self) -> bool { 108 | self.buffer_index < self.buffer_size 109 | } 110 | 111 | fn take_pure_escape(&mut self) -> bool { 112 | if self.buffer_index == 0 && self.buffer_size == 1 && self.buffer[0] == ESCAPE { 113 | // This will set self.might_have_more_data = true, which is fine, 114 | // because that only gets set to true when buffer_size == N, but 115 | // we just checked that it is 1 and not N. 116 | self.clear(); 117 | return true; 118 | } 119 | 120 | false 121 | } 122 | 123 | fn read_more_if_needed(&mut self) -> Option { 124 | if self.has_buffered_data() { 125 | return None; 126 | } 127 | 128 | self.clear(); 129 | 130 | match read_and_retry_on_interrupt(&mut self.input, &mut self.buffer) { 131 | Ok(bytes_read) => { 132 | self.buffer_size = bytes_read; 133 | self.might_have_more_data = bytes_read == N; 134 | None 135 | } 136 | Err(err) => Some(err), 137 | } 138 | } 139 | } 140 | 141 | impl Iterator for BufferedInput { 142 | type Item = io::Result; 143 | 144 | fn next(&mut self) -> Option> { 145 | if !self.has_buffered_data() { 146 | return None; 147 | } 148 | 149 | Some(Ok(self.next_u8())) 150 | } 151 | } 152 | 153 | struct TuiInput { 154 | poll_fds: [libc::pollfd; 2], 155 | sigwinch_pipe: UnixStream, 156 | buffered_input: BufferedInput, 157 | } 158 | 159 | impl TuiInput { 160 | fn new(input: Stdin, sigwinch_pipe: UnixStream) -> TuiInput { 161 | let sigwinch_fd = sigwinch_pipe.as_raw_fd(); 162 | let stdin_fd = input.as_raw_fd(); 163 | 164 | let poll_fds: [libc::pollfd; 2] = [ 165 | libc::pollfd { 166 | fd: sigwinch_fd, 167 | events: libc::POLLIN, 168 | revents: 0, 169 | }, 170 | libc::pollfd { 171 | fd: stdin_fd, 172 | events: libc::POLLIN, 173 | revents: 0, 174 | }, 175 | ]; 176 | 177 | TuiInput { 178 | poll_fds, 179 | sigwinch_pipe, 180 | buffered_input: BufferedInput::new(input), 181 | } 182 | } 183 | 184 | fn get_event_from_buffered_input(&mut self) -> Option> { 185 | if !self.buffered_input.has_buffered_data() { 186 | if let Some(err) = self.buffered_input.read_more_if_needed() { 187 | return Some(Err(err)); 188 | } 189 | } 190 | 191 | if self.buffered_input.take_pure_escape() { 192 | return Some(Ok(TuiEvent::KeyEvent(Key::Esc))); 193 | } 194 | 195 | match self.buffered_input.next() { 196 | Some(Ok(byte)) => match parse_event(byte, &mut self.buffered_input) { 197 | Ok(Event::Key(k)) => Some(Ok(TuiEvent::KeyEvent(k))), 198 | Ok(Event::Mouse(m)) => Some(Ok(TuiEvent::MouseEvent(m))), 199 | Ok(Event::Unsupported(bytes)) => Some(Ok(TuiEvent::Unknown(bytes))), 200 | Err(err) => Some(Err(err)), 201 | }, 202 | Some(Err(err)) => Some(Err(err)), 203 | None => None, 204 | } 205 | } 206 | } 207 | 208 | impl Iterator for TuiInput { 209 | type Item = io::Result; 210 | 211 | fn next(&mut self) -> Option> { 212 | if self.buffered_input.might_have_buffered_data() { 213 | return self.get_event_from_buffered_input(); 214 | } 215 | 216 | let poll_res: Option; 217 | 218 | loop { 219 | match unsafe { libc::poll(self.poll_fds.as_mut_ptr(), 2, POLL_INFINITE_TIMEOUT) } { 220 | -1 => { 221 | let err = io::Error::last_os_error(); 222 | if err.kind() != io::ErrorKind::Interrupted { 223 | poll_res = Some(err); 224 | break; 225 | } 226 | // Try poll again. 227 | } 228 | _ => { 229 | poll_res = None; 230 | break; 231 | } 232 | }; 233 | } 234 | 235 | if let Some(poll_err) = poll_res { 236 | return Some(Err(poll_err)); 237 | } 238 | 239 | if self.poll_fds[SIGWINCH_PIPE_INDEX].revents & libc::POLLIN != 0 { 240 | // Just make this big enough to absorb a bunch of unacknowledged SIGWINCHes. 241 | let mut buf = [0; 32]; 242 | let _ = self.sigwinch_pipe.read(&mut buf); 243 | return Some(Ok(TuiEvent::WinChEvent)); 244 | } 245 | 246 | self.get_event_from_buffered_input() 247 | } 248 | } 249 | 250 | #[derive(Debug)] 251 | pub enum TuiEvent { 252 | WinChEvent, 253 | KeyEvent(Key), 254 | MouseEvent(MouseEvent), 255 | Unknown(Vec), 256 | } 257 | -------------------------------------------------------------------------------- /src/jless.help: -------------------------------------------------------------------------------- 1 |  2 | jless - a terminal JSON viewer 3 | 4 | SUMMARY OF JLESS COMMANDS 5 | 6 | Commands marked with * may be preceded by a number, N, which will 7 | repeatedly perform a command the given number of times. A key 8 | preceded by a caret indicates the Ctrl key; thus ^E is ctrl-E. 9 | 10 | Commands requiring multiple key-presses may be cancelled with the 11 | Escape key. 12 | 13 | q :q[uit] ^c Exit jless. 14 | 15 | F1 :h[elp] Show this help screen. 16 | 17 | ^z Suspend jless. 18 | 19 | MOVING 20 | 21 | j DownArrow * Move focus down one line (or N lines). 22 | ^n Enter 23 | 24 | k UpArrow * Move focus up one line (or N lines). 25 | ^p Backspace 26 | 27 | h LeftArrow When focused on an expanded object or array, collapse the 28 | object or array. Otherwise, move focus to the parent of 29 | the focused node. 30 | 31 | H Focus the parent of the focused node, even if it is an 32 | expanded object or array. 33 | 34 | l RightArrow When focused on a collapsed object or array, expand the 35 | object or array. When focused on an expanded object or 36 | array, move focus to the first child. When focused on 37 | non-container values, does nothing. 38 | 39 | J * Move to the focused node's next sibling 1 or N times. 40 | K * Move to the focused node's previous sibling 1 or N times. 41 | 42 | w * Move forward until the next change in depth 1 or N times. 43 | b * Move backwards until the next change in depth 1 or N times. 44 | 45 | PageDown ^f * Move down by one window (or N windows). 46 | PageUp ^b * Move up by one window (or N windows). 47 | 48 | 0 ^ Move to the first sibling of the focused node's parent. 49 | $ Move to the last sibling of the focused node's parent. 50 | 51 | Home Focus the first line in the input. 52 | End Focus the last line in the input. 53 | 54 | g * Focus the first line in the input if no count is given. If a 55 | count is given, focus that line number. If the line isn't 56 | visible, focus the last visible line before it. 57 | G * Focus the last line in the input if no count is given. If a 58 | count is given, focus that line number, expanding any of its 59 | parent nodes if necessary. 60 | 61 | c Shallow collapse the focused node and all its siblings. 62 | C Deeply collapse the focused node and all its siblings. 63 | e Shallow expand the focused node and all its siblings. 64 | E Deeply expand the focused node and all its siblings. 65 | 66 | Space Toggle the collapsed state of the currently focused node. 67 | 68 | SCROLLING 69 | 70 | ^e * Scroll down one line (or N lines). 71 | ^y * Scroll up one line (or N lines). 72 | 73 | ^d * Scroll down by half the height of the screen (or by N lines). 74 | ^u * Scroll up by half the height of the screen (or by N lines). 75 | For this command and ^d, focus is also moved by the specified 76 | number of lines. If no count is specified, the number of 77 | lines to scroll by is recalled from previous executions. 78 | 79 | zz Move the focused node to the center of the screen. 80 | zt Move the focused node to the top of the screen. 81 | zb Move the focused node to the bottom of the screen. 82 | 83 | . * Scroll a truncated value one char to the right (or N chars). 84 | , * Scroll a truncated value one char to the left (or N chars). 85 | ; Scroll a truncated value all the way to the end, or, if 86 | already at the end, back to the start. 87 | 88 | < Decrease the indentation of every line by one (or N) tabs. 89 | > Increase the indentation of every line by one (or N) tabs. 90 | 91 | COPYING AND PRINTING 92 | 93 | You can copy various parts of the JSON file to your clipboard using 94 | any one of a set of commands starting with 'y'. 95 | 96 | Alternatively, you can print out values using 'p'. This is useful for 97 | viewing long string values all at once, or if the clipboard functionality 98 | is not working; mouse-tracking will be temporarily disabled, allowing you 99 | to use your terminal's native clipboard capabilities to select and copy 100 | the desired text. 101 | 102 | yy pp Copy/print the currently focused value, pretty printed. When focused 103 | on the key/value pair of an object, this will not include the key. 104 | yv pv Copy/print the currently focused value, like yy/pp, but "nicely" 105 | printed on one line with spaces instead of pretty printed. 106 | ys ps When the currently focused value is a string, copy/print the contents 107 | of the string, with all escape sequences, except control characters, 108 | unescaped. 109 | 110 | yk pk Copy/print the object key on the currently focused line. When in data 111 | mode this will not include quotes around the key if the key is a 112 | valid JavaScript identifier. 113 | 114 | yp pP Copy/print the path from the top level JSON root to the currently 115 | focused value. Object keys will be accessed using ".key" unless they 116 | are not valid JavaScript identifiers. 117 | yb pb Same as yp, but use square brackets for all object key accesses. 118 | Useful if getting the path for use in an environment that doesn't 119 | support the ".key" syntax, e.g. Python. 120 | yq pq Copy/print a path that can be used by jq to filter the input JSON and 121 | return the currently focused value. 122 | 123 | WRITING 124 | 125 | :w[rite] Write the input JSON to a file. 126 | :w[rite]! Write the input JSON to a file, even if the file already 127 | exists. 128 | 129 | SEARCH 130 | 131 | jless supports full-text search over the input JSON. 132 | 133 | /pattern * Search forward for the given pattern (or its Nth occurrence). 134 | ?pattern * Search backwards for the given pattern (or its Nth occurrence). 135 | 136 | * * Move to the next occurrence of the object key on the focused 137 | line (or move forward N occurrences) 138 | # * Move to the previous occurrence of the object key on the 139 | focused line (or move backwards N occurrences) 140 | 141 | n * Move in the search direction to the next match (or forward 142 | N matches). 143 | N * Move in the opposite of the search direction to the previous 144 | match (or previous N matches). 145 | 146 | Searching uses "smart case" by default. If the input pattern doesn't 147 | contain any capital letters, a case insensitive search will be 148 | performed. If there are any capital letters, it will be case sensitive. 149 | You can force a case-sensitive search by appending '/s' to your query. 150 | 151 | A trailing slash will be removed from a pattern; to search for a 152 | pattern ending in '/' (or '/s'), just add another '/' to the end. 153 | 154 | Search patterns are interpreted as mostly standard regular expressions, 155 | with one exception. Because JSON data contains many square and curly 156 | brackets ("[]{}"), these characters do *not* take on their usual 157 | meanings (specifying characters classes and repetition counts 158 | respectively) and are instead interpreted literally. 159 | 160 | To use character classes or repetition counts, escape these characters 161 | with a backslash. 162 | 163 | Some examples: 164 | 165 | /[1, 2, 3] matches an array: [1, 2, 3] 166 | /\[bch\]at matches "bat", "cat" or "hat" 167 | /{} matches an empty object 168 | /(ha)\{2,3\} matches "haha" or "hahaha" 169 | 170 | For exhaustive documentation of the supported regular expression syntax, 171 | see the following documentation of the underlying regex engine: 172 | 173 | https://docs.rs/regex/latest/regex/index.html#syntax 174 | 175 | SEARCH INPUT 176 | 177 | The search is *not* performed over the original input, but over a 178 | single-line "nicely" formatted version of the input JSON. Consider the 179 | following two ways to format an equivalent JSON blob: 180 | 181 | {"a":1,"b":true,"c":[null,{},[],"hello"]} 182 | 183 | { 184 | "a": 1, 185 | "b": true, 186 | "c": [ 187 | null, 188 | {}, 189 | [], 190 | "hello" 191 | ] 192 | } 193 | 194 | jless will create an internal representation formatted as follows: 195 | 196 | { "a": 1, "b": true, "c": [null, {}, [], "hello"] } 197 | 198 | (No spaces inside empty objects or arrays, one space inside objects 199 | with values, no spaces inside array square brackets, no space between 200 | an object key and ':', one space after the ':', and one space after 201 | commas separating object entries and array elements.) 202 | 203 | Searching will be performed over this internal representation so that 204 | patterns can include multiple elements without worrying about 205 | newlines or the exact input format. 206 | 207 | When the input is newline-delimited JSON, an actual newline will 208 | separate each top-level JSON element in the internal representation. 209 | 210 | DATA MODE VS LINE MODE 211 | 212 | jless starts in "data" mode, which displays the JSON data in a more 213 | streamlined fashion: no closing delimiters for objects or arrays, 214 | no trailing commas, no quotes around object keys that are valid 215 | identifiers in JavaScript. It also shows single-line previews of 216 | objects and arrays, and array indexes before array elements. Note 217 | that when using full-text search, object keys will still be 218 | surrounded by quotes. 219 | 220 | By pressing 'm', you can switch jless to "line" mode, which displays 221 | the input as pretty-printed JSON. 222 | 223 | In line mode you can press '%' when focused on an open or close 224 | delimiter of an object or array to jump to its matching pair. 225 | 226 | LINE NUMBERS 227 | 228 | jless supports displaying line numbers, and does so by default. The line 229 | numbers do not reflect the position of a node in the original input, but 230 | rather what line the node would appear on if the original input were 231 | pretty printed. 232 | 233 | jless also supports relative line numbers. When this is enabled, the 234 | number displayed next to each line will indicate how many lines away it 235 | is from the currently focused line. This makes it easier to use the j 236 | and k commands with specified counts. 237 | 238 | The appearance of line numbers can be configured via command line flags: 239 | 240 | -n, --line-numbers Show absolute line numbers. 241 | -N, --no-line-numbers Don't show absolute line numbers. 242 | 243 | -r, --relative-line-numbers Show relative line numbers. 244 | -R, --no-relative-line-numbers Don't show relative line numbers. 245 | 246 | As well as at runtime: 247 | 248 | :set number Show absolute line numbers. 249 | :set nonumber Don't show absolute line numbers. 250 | :set number! Toggle whether showing absolute line numbers. 251 | 252 | :set relativenumber Show relative line numbers. 253 | :set norelativenumber Don't show relative line numbers. 254 | :set relativenumber! Toggle whether showing relative line numbers. 255 | 256 | When just using relative line numbers, "0" will be displayed next to the 257 | currently focused line. When both flags are set, the absolute line 258 | number will be displayed next to the focused lines, and all other line 259 | numbers will be relative. This matches vim's behavior. 260 | -------------------------------------------------------------------------------- /src/jsonparser.rs: -------------------------------------------------------------------------------- 1 | use logos::{Lexer, Logos}; 2 | 3 | use crate::flatjson::{ContainerType, Index, OptionIndex, Row, Value}; 4 | use crate::jsontokenizer::JsonToken; 5 | 6 | struct JsonParser<'a> { 7 | tokenizer: Lexer<'a, JsonToken>, 8 | parents: Vec, 9 | rows: Vec, 10 | pretty_printed: String, 11 | max_depth: usize, 12 | 13 | peeked_token: Option>, 14 | } 15 | 16 | pub fn parse(json: String) -> Result<(Vec, String, usize), String> { 17 | let mut parser = JsonParser { 18 | tokenizer: JsonToken::lexer(&json), 19 | parents: vec![], 20 | rows: vec![], 21 | pretty_printed: String::new(), 22 | max_depth: 0, 23 | peeked_token: None, 24 | }; 25 | 26 | parser.parse_top_level_json()?; 27 | 28 | Ok((parser.rows, parser.pretty_printed, parser.max_depth)) 29 | } 30 | 31 | impl<'a> JsonParser<'a> { 32 | fn next_token(&mut self) -> Option { 33 | if self.peeked_token.is_some() { 34 | self.peeked_token.take().unwrap() 35 | } else { 36 | self.tokenizer.next() 37 | } 38 | } 39 | 40 | fn advance(&mut self) { 41 | self.next_token(); 42 | } 43 | 44 | fn advance_and_consume_whitespace(&mut self) { 45 | self.advance(); 46 | self.consume_whitespace(); 47 | } 48 | 49 | fn peek_token_or_eof(&mut self) -> Option { 50 | if self.peeked_token.is_none() { 51 | self.peeked_token = Some(self.tokenizer.next()); 52 | } 53 | 54 | self.peeked_token.unwrap() 55 | } 56 | 57 | fn peek_token(&mut self) -> Result { 58 | self.peek_token_or_eof() 59 | .ok_or_else(|| "Unexpected EOF".to_string()) 60 | } 61 | 62 | fn unexpected_token(&mut self) -> Result { 63 | Err(format!("Unexpected token: {:?}", self.peek_token())) 64 | } 65 | 66 | fn consume_whitespace(&mut self) { 67 | while let Some(JsonToken::Whitespace | JsonToken::Newline) = self.peek_token_or_eof() { 68 | self.advance(); 69 | } 70 | } 71 | 72 | fn parse_top_level_json(&mut self) -> Result<(), String> { 73 | self.consume_whitespace(); 74 | let mut prev_top_level = self.parse_elem()?; 75 | let mut num_child = 0; 76 | 77 | loop { 78 | self.consume_whitespace(); 79 | 80 | if self.peek_token_or_eof().is_none() { 81 | break; 82 | } 83 | 84 | self.pretty_printed.push('\n'); 85 | let next_top_level = self.parse_elem()?; 86 | num_child += 1; 87 | 88 | self.rows[next_top_level].prev_sibling = OptionIndex::Index(prev_top_level); 89 | self.rows[next_top_level].index_in_parent = num_child; 90 | self.rows[prev_top_level].next_sibling = OptionIndex::Index(next_top_level); 91 | 92 | prev_top_level = next_top_level; 93 | } 94 | 95 | Ok(()) 96 | } 97 | 98 | fn parse_elem(&mut self) -> Result { 99 | self.consume_whitespace(); 100 | 101 | self.max_depth = self.max_depth.max(self.parents.len()); 102 | 103 | loop { 104 | match self.peek_token()? { 105 | JsonToken::OpenCurly => { 106 | return self.parse_object(); 107 | } 108 | JsonToken::OpenSquare => { 109 | return self.parse_array(); 110 | } 111 | JsonToken::Null => { 112 | return self.parse_null(); 113 | } 114 | JsonToken::True => { 115 | return self.parse_bool(true); 116 | } 117 | JsonToken::False => { 118 | return self.parse_bool(false); 119 | } 120 | JsonToken::Number => { 121 | return self.parse_number(); 122 | } 123 | JsonToken::String => { 124 | return self.parse_string(); 125 | } 126 | 127 | JsonToken::Whitespace | JsonToken::Newline => { 128 | panic!("Should have just consumed whitespace"); 129 | } 130 | 131 | JsonToken::Error => { 132 | return Err("Parse error".to_string()); 133 | } 134 | JsonToken::CloseCurly 135 | | JsonToken::CloseSquare 136 | | JsonToken::Colon 137 | | JsonToken::Comma => { 138 | return Err(format!("Unexpected character: {:?}", self.tokenizer.span())); 139 | } 140 | } 141 | } 142 | } 143 | 144 | fn parse_array(&mut self) -> Result { 145 | let open_value = Value::OpenContainer { 146 | container_type: ContainerType::Array, 147 | collapsed: false, 148 | // To be set when parsing is complete. 149 | first_child: 0, 150 | close_index: 0, 151 | }; 152 | 153 | let array_open_index = self.create_row(open_value); 154 | 155 | self.parents.push(array_open_index); 156 | self.pretty_printed.push('['); 157 | self.advance_and_consume_whitespace(); 158 | 159 | let mut prev_sibling = OptionIndex::Nil; 160 | let mut num_children = 0; 161 | 162 | loop { 163 | if num_children != 0 { 164 | match self.peek_token()? { 165 | // Great, we needed a comma; eat it up. 166 | JsonToken::Comma => self.advance_and_consume_whitespace(), 167 | // We're going to peek again below and check for ']', so we don't 168 | // need to do anything. 169 | JsonToken::CloseSquare => {} 170 | _ => return self.unexpected_token(), 171 | } 172 | } 173 | 174 | if self.peek_token()? == JsonToken::CloseSquare { 175 | self.advance(); 176 | break; 177 | } 178 | 179 | // Add comma to pretty printed version _after_ we know 180 | // we didn't see a CloseSquare so we don't add a trailing comma. 181 | if num_children != 0 { 182 | self.pretty_printed.push_str(", "); 183 | } 184 | 185 | let child = self.parse_elem()?; 186 | self.consume_whitespace(); 187 | 188 | if num_children == 0 { 189 | match self.rows[array_open_index].value { 190 | Value::OpenContainer { 191 | ref mut first_child, 192 | .. 193 | } => { 194 | *first_child = child; 195 | } 196 | _ => panic!("Must be Array!"), 197 | } 198 | } 199 | 200 | self.rows[child].prev_sibling = prev_sibling; 201 | self.rows[child].index_in_parent = num_children; 202 | if let OptionIndex::Index(prev) = prev_sibling { 203 | self.rows[prev].next_sibling = OptionIndex::Index(child); 204 | } 205 | 206 | num_children += 1; 207 | prev_sibling = OptionIndex::Index(child); 208 | } 209 | 210 | self.parents.pop(); 211 | 212 | if num_children == 0 { 213 | self.rows[array_open_index].value = Value::EmptyArray; 214 | self.rows[array_open_index].range.end = self.rows[array_open_index].range.start + 2; 215 | } else { 216 | let close_value = Value::CloseContainer { 217 | container_type: ContainerType::Array, 218 | collapsed: false, 219 | last_child: prev_sibling.unwrap(), 220 | open_index: array_open_index, 221 | }; 222 | 223 | let array_close_index = self.create_row(close_value); 224 | 225 | // Update end of the Array range; we add the ']' to pretty_printed 226 | // below, hence the + 1. 227 | self.rows[array_open_index].range.end = self.pretty_printed.len() + 1; 228 | 229 | match self.rows[array_open_index].value { 230 | Value::OpenContainer { 231 | ref mut close_index, 232 | .. 233 | } => { 234 | *close_index = array_close_index; 235 | } 236 | _ => panic!("Must be Array!"), 237 | } 238 | } 239 | 240 | self.pretty_printed.push(']'); 241 | Ok(array_open_index) 242 | } 243 | 244 | fn parse_object(&mut self) -> Result { 245 | let open_value = Value::OpenContainer { 246 | container_type: ContainerType::Object, 247 | collapsed: false, 248 | // To be set when parsing is complete. 249 | first_child: 0, 250 | close_index: 0, 251 | }; 252 | 253 | let object_open_index = self.create_row(open_value); 254 | 255 | self.parents.push(object_open_index); 256 | self.pretty_printed.push('{'); 257 | self.advance_and_consume_whitespace(); 258 | 259 | let mut prev_sibling = OptionIndex::Nil; 260 | let mut num_children = 0; 261 | 262 | loop { 263 | if num_children != 0 { 264 | match self.peek_token()? { 265 | // Great, we needed a comma; eat it up. 266 | JsonToken::Comma => self.advance_and_consume_whitespace(), 267 | // We're going to peek again below and check for '}', so we don't 268 | // need to do anything. 269 | JsonToken::CloseCurly => {} 270 | _ => return self.unexpected_token(), 271 | } 272 | } 273 | 274 | if self.peek_token()? == JsonToken::CloseCurly { 275 | self.advance(); 276 | break; 277 | } 278 | 279 | // Add comma to pretty printed version _after_ we know 280 | // we didn't see a CloseSquare so we don't add a trailing comma. 281 | if num_children != 0 { 282 | self.pretty_printed.push_str(", "); 283 | } else { 284 | // Add space inside objects. 285 | self.pretty_printed.push(' '); 286 | } 287 | 288 | if self.peek_token()? != JsonToken::String { 289 | return self.unexpected_token(); 290 | } 291 | 292 | let key_range = { 293 | let key_range_start = self.pretty_printed.len(); 294 | let key_span_len = self.tokenizer.span().len(); 295 | let key_range = key_range_start..key_range_start + key_span_len; 296 | 297 | self.pretty_printed.push_str(self.tokenizer.slice()); 298 | self.advance_and_consume_whitespace(); 299 | key_range 300 | }; 301 | 302 | if self.peek_token()? != JsonToken::Colon { 303 | return self.unexpected_token(); 304 | } 305 | self.advance_and_consume_whitespace(); 306 | self.pretty_printed.push_str(": "); 307 | 308 | let child = self.parse_elem()?; 309 | self.rows[child].key_range = Some(key_range); 310 | self.consume_whitespace(); 311 | 312 | if num_children == 0 { 313 | match self.rows[object_open_index].value { 314 | Value::OpenContainer { 315 | ref mut first_child, 316 | .. 317 | } => { 318 | *first_child = child; 319 | } 320 | _ => panic!("Must be Object!"), 321 | } 322 | } 323 | 324 | self.rows[child].prev_sibling = prev_sibling; 325 | self.rows[child].index_in_parent = num_children; 326 | if let OptionIndex::Index(prev) = prev_sibling { 327 | self.rows[prev].next_sibling = OptionIndex::Index(child); 328 | } 329 | 330 | num_children += 1; 331 | prev_sibling = OptionIndex::Index(child); 332 | } 333 | 334 | self.parents.pop(); 335 | 336 | if num_children == 0 { 337 | self.rows[object_open_index].value = Value::EmptyObject; 338 | self.rows[object_open_index].range.end = self.rows[object_open_index].range.start + 2; 339 | } else { 340 | // Print space inside closing brace. 341 | self.pretty_printed.push(' '); 342 | 343 | let close_value = Value::CloseContainer { 344 | container_type: ContainerType::Object, 345 | collapsed: false, 346 | last_child: prev_sibling.unwrap(), 347 | open_index: object_open_index, 348 | }; 349 | 350 | let object_close_index = self.create_row(close_value); 351 | 352 | // Update end of the Object range; we add the '}' to pretty_printed 353 | // below, hence the + 1. 354 | self.rows[object_open_index].range.end = self.pretty_printed.len() + 1; 355 | 356 | match self.rows[object_open_index].value { 357 | Value::OpenContainer { 358 | ref mut close_index, 359 | .. 360 | } => { 361 | *close_index = object_close_index; 362 | } 363 | _ => panic!("Must be Object!"), 364 | } 365 | } 366 | 367 | self.pretty_printed.push('}'); 368 | Ok(object_open_index) 369 | } 370 | 371 | fn parse_null(&mut self) -> Result { 372 | self.advance(); 373 | let row_index = self.create_row(Value::Null); 374 | self.rows[row_index].range.end = self.rows[row_index].range.start + 4; 375 | self.pretty_printed.push_str("null"); 376 | Ok(row_index) 377 | } 378 | 379 | fn parse_bool(&mut self, b: bool) -> Result { 380 | self.advance(); 381 | 382 | let row_index = self.create_row(Value::Boolean); 383 | let (bool_str, len) = if b { ("true", 4) } else { ("false", 5) }; 384 | 385 | self.rows[row_index].range.end = self.rows[row_index].range.start + len; 386 | self.pretty_printed.push_str(bool_str); 387 | 388 | Ok(row_index) 389 | } 390 | 391 | fn parse_number(&mut self) -> Result { 392 | let row_index = self.create_row(Value::Number); 393 | self.pretty_printed.push_str(self.tokenizer.slice()); 394 | 395 | self.rows[row_index].range.end = 396 | self.rows[row_index].range.start + self.tokenizer.slice().len(); 397 | 398 | self.advance(); 399 | Ok(row_index) 400 | } 401 | 402 | fn parse_string(&mut self) -> Result { 403 | let row_index = self.create_row(Value::String); 404 | 405 | // The token includes the quotation marks. 406 | self.pretty_printed.push_str(self.tokenizer.slice()); 407 | self.rows[row_index].range.end = 408 | self.rows[row_index].range.start + self.tokenizer.slice().len(); 409 | 410 | self.advance(); 411 | Ok(row_index) 412 | } 413 | 414 | // Add a new row to the FlatJson representation. 415 | // 416 | // self.pretty_printed should NOT include the added row yet; 417 | // we use the current length of self.pretty_printed as the 418 | // starting index of the row's range. 419 | fn create_row(&mut self, value: Value) -> usize { 420 | let index = self.rows.len(); 421 | 422 | let parent = match self.parents.last() { 423 | None => OptionIndex::Nil, 424 | Some(row_index) => OptionIndex::Index(*row_index), 425 | }; 426 | 427 | let range_start = self.pretty_printed.len(); 428 | 429 | self.rows.push(Row { 430 | // Set correctly by us 431 | parent, 432 | depth: self.parents.len(), 433 | value, 434 | 435 | // The start of this range is set by us, but then we set 436 | // the end when we're done parsing the row. We'll set 437 | // the default end to be one character so we don't have to 438 | // update it after ']' and '}'. 439 | range: range_start..range_start + 1, 440 | 441 | // To be filled in by caller 442 | prev_sibling: OptionIndex::Nil, 443 | next_sibling: OptionIndex::Nil, 444 | index_in_parent: 0, 445 | key_range: None, 446 | }); 447 | 448 | index 449 | } 450 | } 451 | #[cfg(test)] 452 | mod tests { 453 | use super::*; 454 | 455 | #[test] 456 | fn test_row_ranges() { 457 | // 0 2 7 10 15 21 26 32 39 42 458 | let json = r#"{ "a": 1, "b": true, "c": null, "ddd": [] }"#.to_owned(); 459 | let (rows, _, _) = parse(json).unwrap(); 460 | 461 | assert_eq!(rows[0].range, 0..43); // Object 462 | assert_eq!(rows[1].key_range, Some(2..5)); // "a": 1 463 | assert_eq!(rows[1].range, 7..8); // "a": 1 464 | assert_eq!(rows[2].key_range, Some(10..13)); // "b": true 465 | assert_eq!(rows[2].range, 15..19); // "b": true 466 | assert_eq!(rows[3].key_range, Some(21..24)); // "c": null 467 | assert_eq!(rows[3].range, 26..30); // "c": null 468 | assert_eq!(rows[4].range, 39..41); // "ddd": [] 469 | assert_eq!(rows[5].range, 42..43); // } 470 | 471 | // 01 5 14 21 23 472 | let json = r#"[14, "apple", false, {}]"#.to_owned(); 473 | let (rows, _, _) = parse(json).unwrap(); 474 | 475 | assert_eq!(rows[0].range, 0..24); // Array 476 | assert_eq!(rows[1].range, 1..3); // 14 477 | assert_eq!(rows[2].range, 5..12); // "apple" 478 | assert_eq!(rows[3].range, 14..19); // false 479 | assert_eq!(rows[4].range, 21..23); // {} 480 | assert_eq!(rows[5].range, 23..24); // ] 481 | 482 | // 01 3 10 17 23 27 32 37 40 46 51 483 | let json = r#"[{ "abc": "str", "de": 14, "f": null }, true, false]"#.to_owned(); 484 | let (rows, _, _) = parse(json).unwrap(); 485 | 486 | assert_eq!(rows[0].range, 0..52); // Array 487 | assert_eq!(rows[1].range, 1..38); // Object 488 | assert_eq!(rows[2].key_range, Some(3..8)); // "abc": "str" 489 | assert_eq!(rows[2].range, 10..15); // "abc": "str" 490 | assert_eq!(rows[3].key_range, Some(17..21)); // "de": 14 491 | assert_eq!(rows[3].range, 23..25); // "de": 14 492 | assert_eq!(rows[4].key_range, Some(27..30)); // "f": null 493 | assert_eq!(rows[4].range, 32..36); // "f": null 494 | assert_eq!(rows[5].range, 37..38); // } 495 | assert_eq!(rows[6].range, 40..44); // true 496 | assert_eq!(rows[7].range, 46..51); // false 497 | assert_eq!(rows[8].range, 51..52); // ] 498 | } 499 | } 500 | -------------------------------------------------------------------------------- /src/jsonstringunescaper.rs: -------------------------------------------------------------------------------- 1 | use std::fmt; 2 | use std::fmt::Write; 3 | 4 | #[derive(Debug)] 5 | pub struct UnescapeError { 6 | index: usize, 7 | codepoint_chars: [u8; 4], 8 | error: UnicodeError, 9 | } 10 | 11 | #[derive(Debug)] 12 | enum UnicodeError { 13 | UnexpectedLowSurrogate, 14 | UnmatchedHighSurrogate, 15 | } 16 | 17 | impl fmt::Display for UnescapeError { 18 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 19 | write!(f, "unescaping error at char {}: ", self.index)?; 20 | let codepoint_chars = std::str::from_utf8(&self.codepoint_chars).unwrap(); 21 | match &self.error { 22 | UnicodeError::UnexpectedLowSurrogate => { 23 | write!(f, "unexpected low surrogate \"\\u{codepoint_chars}\"") 24 | } 25 | UnicodeError::UnmatchedHighSurrogate => write!( 26 | f, 27 | "high surrogate \"\\u{codepoint_chars}\" not followed by low surrogate" 28 | ), 29 | } 30 | } 31 | } 32 | 33 | enum DecodedCodepoint { 34 | Char(char), 35 | LowSurrogate(u16), 36 | HighSurrogate(u16), 37 | } 38 | 39 | // Unescapes a syntactically valid JSON string into a valid UTF-8 string. 40 | // If [escape_control_characters] is true, Unicode control characters will be 41 | // be escaped. 42 | // 43 | // This makes the assumption that the only characters following a '\' are: 44 | // - single character escapes: "\/bfnrt 45 | // - a unicode character escape: uxxxx 46 | // 47 | // Unicode escapes are exactly four characters, and essentially represent 48 | // UTF-16 encoded codepoints. 49 | // 50 | // Unicode codepoints between U+010000 and U+10FFFF (codepoints outside 51 | // the Basic Multilingual Plane) must be encoded as a surrogate pair. 52 | // 53 | // For more information, and a walkthrough of how to convert the surrogate pairs 54 | // back into an actual char, see: 55 | // https://en.wikipedia.org/wiki/UTF-16#Code_points_from_U+010000_to_U+10FFFF 56 | fn unescape_json_string(s: &str, escape_control_characters: bool) -> Result { 57 | let mut chars = s.chars(); 58 | let mut unescaped = String::with_capacity(s.len()); 59 | let mut index = 1; 60 | 61 | while let Some(ch) = chars.next() { 62 | index += 1; 63 | if ch != '\\' { 64 | if escape_control_characters && is_control(ch) { 65 | unescaped.push_str("\\u00"); 66 | write!(unescaped, "{:02X}", ch as u32).unwrap(); 67 | } else { 68 | unescaped.push(ch); 69 | } 70 | continue; 71 | } 72 | 73 | let escaped = chars.next().unwrap(); 74 | index += 1; 75 | 76 | match escaped { 77 | '"' => unescaped.push('"'), 78 | '\\' => unescaped.push('\\'), 79 | '/' => unescaped.push('/'), 80 | // '\b' is backspace, a control character. 81 | 'b' => { 82 | if escape_control_characters { 83 | unescaped.push_str("\\b"); 84 | } else { 85 | unescaped.push(0x08 as char); 86 | } 87 | } 88 | 'f' => unescaped.push('\x0c'), 89 | 'n' => unescaped.push('\n'), 90 | 'r' => unescaped.push('\r'), 91 | 't' => unescaped.push('\t'), 92 | 'u' => { 93 | let (codepoint, codepoint_chars) = parse_codepoint_from_chars(&mut chars); 94 | index += 4; 95 | 96 | match decode_codepoint(codepoint) { 97 | DecodedCodepoint::Char(ch) => { 98 | if escape_control_characters && is_control(ch) { 99 | unescaped.push_str("\\u"); 100 | unescaped.push(codepoint_chars[0] as char); 101 | unescaped.push(codepoint_chars[1] as char); 102 | unescaped.push(codepoint_chars[2] as char); 103 | unescaped.push(codepoint_chars[3] as char); 104 | } else { 105 | unescaped.push(ch) 106 | } 107 | } 108 | DecodedCodepoint::LowSurrogate(_) => { 109 | return Err(UnescapeError { 110 | index: index - 6, 111 | codepoint_chars, 112 | error: UnicodeError::UnexpectedLowSurrogate, 113 | }); 114 | } 115 | DecodedCodepoint::HighSurrogate(hs) => match (chars.next(), chars.next()) { 116 | (Some('\\'), Some('u')) => { 117 | index += 2; 118 | let (codepoint, _) = parse_codepoint_from_chars(&mut chars); 119 | index += 4; 120 | 121 | match decode_codepoint(codepoint) { 122 | DecodedCodepoint::LowSurrogate(ls) => { 123 | let codepoint = (hs as u32) * 0x400 + (ls as u32) + 0x10000; 124 | unescaped.push(char::from_u32(codepoint).unwrap()); 125 | } 126 | _ => { 127 | return Err(UnescapeError { 128 | index, 129 | codepoint_chars, 130 | error: UnicodeError::UnmatchedHighSurrogate, 131 | }); 132 | } 133 | } 134 | } 135 | _ => { 136 | return Err(UnescapeError { 137 | index, 138 | codepoint_chars, 139 | error: UnicodeError::UnmatchedHighSurrogate, 140 | }); 141 | } 142 | }, 143 | } 144 | } 145 | _ => panic!("Unexpected escape character in JSON string: {}", ch), 146 | } 147 | } 148 | 149 | Ok(unescaped) 150 | } 151 | 152 | // Unescapes a syntactically valid JSON string into a valid UTF-8 string, but 153 | // leaves control characters escaped. 154 | pub fn safe_unescape_json_string(s: &str) -> Result { 155 | unescape_json_string(s, true) 156 | } 157 | 158 | // Unescapes a syntactically valid JSON string into a valid UTF-8 string, including 159 | // control characters. 160 | #[allow(dead_code)] // Only used with #[cfg(feature = "sexp")], but we want to write 161 | // regular tests for it 162 | pub fn unsafe_unescape_json_string(s: &str) -> Result { 163 | unescape_json_string(s, false) 164 | } 165 | 166 | fn is_control(ch: char) -> bool { 167 | matches!(ch as u32, 0x00..=0x1F | 0x7F..=0x9F) 168 | } 169 | 170 | // Consumes four hex characters from a Chars iterator, and converts it to a u16. 171 | // Also returns the four original characters as a mini [u8] that can be safely 172 | // interpreted as a str. 173 | fn parse_codepoint_from_chars(chars: &mut std::str::Chars<'_>) -> (u16, [u8; 4]) { 174 | let mut codepoint = 0; 175 | let chars = [ 176 | chars.next().unwrap(), 177 | chars.next().unwrap(), 178 | chars.next().unwrap(), 179 | chars.next().unwrap(), 180 | ]; 181 | let utf8_chars = [ 182 | chars[0] as u8, 183 | chars[1] as u8, 184 | chars[2] as u8, 185 | chars[3] as u8, 186 | ]; 187 | codepoint += hex_char_to_int(chars[0]) * 0x1000; 188 | codepoint += hex_char_to_int(chars[1]) * 0x0100; 189 | codepoint += hex_char_to_int(chars[2]) * 0x0010; 190 | codepoint += hex_char_to_int(chars[3]); 191 | (codepoint, utf8_chars) 192 | } 193 | 194 | fn hex_char_to_int(ch: char) -> u16 { 195 | match ch { 196 | '0'..='9' => (ch as u16) - ('0' as u16), 197 | 'a'..='f' => (ch as u16) - ('a' as u16) + 10, 198 | 'A'..='f' => (ch as u16) - ('A' as u16) + 10, 199 | _ => panic!("Unexpected non-hex digit: {}", ch), 200 | } 201 | } 202 | 203 | // Interprets a codepoint in the Basic Multilingual Plane as either an actual 204 | // char, or one of a surrogate pair. The value associated with the surrogate 205 | // has had the offset removed. 206 | fn decode_codepoint(codepoint: u16) -> DecodedCodepoint { 207 | match codepoint { 208 | 0xD800..=0xDBFF => DecodedCodepoint::HighSurrogate(codepoint - 0xD800), 209 | 0xDC00..=0xDFFF => DecodedCodepoint::LowSurrogate(codepoint - 0xDC00), 210 | _ => DecodedCodepoint::Char(char::from_u32(codepoint as u32).unwrap()), 211 | } 212 | } 213 | 214 | #[cfg(test)] 215 | mod tests { 216 | use super::*; 217 | 218 | #[track_caller] 219 | fn check(escaped: &str, expected_unescaped: &str) { 220 | let unescaped = match safe_unescape_json_string(escaped) { 221 | Ok(s) => s, 222 | Err(err) => format!("ERR: {err}"), 223 | }; 224 | 225 | assert_eq!(expected_unescaped, &unescaped); 226 | } 227 | 228 | #[track_caller] 229 | fn check_unsafe(escaped: &str, expected_unescaped: &str) { 230 | let unescaped = match unsafe_unescape_json_string(escaped) { 231 | Ok(s) => s, 232 | Err(err) => format!("ERR: {err}"), 233 | }; 234 | 235 | assert_eq!(expected_unescaped, &unescaped); 236 | } 237 | 238 | #[test] 239 | fn test_unescape_json_string() { 240 | // Ok 241 | check("abc", "abc"); 242 | check("abc \\\\ \\\"", "abc \\ \""); 243 | check("abc \\n \\t \\r", "abc \n \t \r"); 244 | check("€ \\u20AC", "€ \u{20AC}"); 245 | check("𐐷 \\uD801\\uDC37", "𐐷 \u{10437}"); 246 | 247 | // Control characters are escaped 248 | check("12x\\b34", "12x\\b34"); 249 | check( 250 | "\\u0000 | \\u001f | \\u0020 | \\u007e | \\u007f | \\u0080 | \\u009F | \\u00a0", 251 | "\\u0000 | \\u001f | \u{0020} | \u{007e} | \\u007f | \\u0080 | \\u009F | \u{00a0}", 252 | ); 253 | 254 | // But not if unsafe is called 255 | check_unsafe("12x\\b34", "12x\x0834"); 256 | check_unsafe( 257 | "\\u0000 | \\u001f | \\u0020 | \\u007e | \\u007f | \\u0080 | \\u009F | \\u00a0", 258 | "\x00 | \x1f | \u{0020} | \u{007e} | \x7f | \u{0080} | \u{009F} | \u{00a0}", 259 | ); 260 | 261 | // Non-ASCII unescaped control codes also get escaped 262 | check("12 \u{0080} 34", "12 \\u0080 34"); 263 | 264 | // But not if unsafe is called 265 | check_unsafe("12 \u{0080} 34", "12 \u{0080} 34"); 266 | 267 | // Errors; make sure index is computed properly. 268 | check( 269 | "abc 𐐷 \\uD801\\uDC37 \\uD801", 270 | // "abc 𐐷 \uD801\uDC37 \uD801" 271 | // 0 2 4 6 8 0 2 4 6 8 0 2 4 6 272 | "ERR: unescaping error at char 26: high surrogate \"\\uD801\" not followed by low surrogate", 273 | ); 274 | 275 | check( 276 | "abc 𐐷 \\uD801\\uDC37 \\uDC37", 277 | // "abc 𐐷 \uD801\uDC37 \uDC37" 278 | // 0 2 4 6 8 0 2 4 6 8 0 2 4 6 279 | "ERR: unescaping error at char 20: unexpected low surrogate \"\\uDC37\"", 280 | ); 281 | } 282 | } 283 | -------------------------------------------------------------------------------- /src/jsontokenizer.rs: -------------------------------------------------------------------------------- 1 | use logos::Logos; 2 | 3 | // A basic JSON tokenizer 4 | 5 | #[derive(Logos, Debug, Copy, Clone, PartialEq)] 6 | pub enum JsonToken { 7 | // Characters 8 | #[token("{")] 9 | OpenCurly, 10 | #[token("}")] 11 | CloseCurly, 12 | #[token("[")] 13 | OpenSquare, 14 | #[token("]")] 15 | CloseSquare, 16 | #[token(":")] 17 | Colon, 18 | #[token(",")] 19 | Comma, 20 | #[token("null")] 21 | Null, 22 | #[token("true")] 23 | True, 24 | #[token("false")] 25 | False, 26 | #[regex(r"-?(0|([1-9][0-9]*))(\.[0-9]+)?([eE][-+]?[0-9]+)?")] 27 | Number, 28 | // I get an error when I do [0-9a-fA-F]{4}. 29 | #[regex("\"((\\\\([\"\\\\/bfnrt]|u[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F]))|[^\"\\\\\x00-\x1F])*\"")] 30 | String, 31 | 32 | // Whitespace; need separate newline token to handle newline-delimited JSON. 33 | #[token("\n")] 34 | Newline, 35 | #[regex("[ \t\r]+", logos::skip)] 36 | Whitespace, 37 | 38 | #[error] 39 | Error, 40 | } 41 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | // I don't like this rule because it changes the semantic 2 | // structure of the code. 3 | #![allow(clippy::collapsible_else_if)] 4 | // Sometimes "x >= y + 1" is semantically clearer than "x > y" 5 | #![allow(clippy::int_plus_one)] 6 | 7 | extern crate lazy_static; 8 | extern crate libc_stdhandle; 9 | 10 | use std::fs::File; 11 | use std::io; 12 | use std::io::Read; 13 | use std::path::PathBuf; 14 | 15 | use clap::Parser; 16 | use termion::cursor::HideCursor; 17 | use termion::input::MouseTerminal; 18 | use termion::raw::IntoRawMode; 19 | use termion::screen::AlternateScreen; 20 | 21 | mod app; 22 | mod flatjson; 23 | mod highlighting; 24 | mod input; 25 | mod jsonparser; 26 | mod jsonstringunescaper; 27 | mod jsontokenizer; 28 | mod lineprinter; 29 | mod options; 30 | mod screenwriter; 31 | mod search; 32 | mod terminal; 33 | mod truncatedstrview; 34 | mod types; 35 | mod viewer; 36 | mod yamlparser; 37 | 38 | use app::App; 39 | use options::{DataFormat, Opt}; 40 | 41 | fn main() { 42 | let opt = Opt::parse(); 43 | 44 | let (input_string, input_filename) = match get_input_and_filename(&opt) { 45 | Ok(input_and_filename) => input_and_filename, 46 | Err(err) => { 47 | eprintln!("Unable to get input: {err}"); 48 | std::process::exit(1); 49 | } 50 | }; 51 | 52 | let data_format = determine_data_format(opt.data_format(), &input_filename); 53 | 54 | if !isatty::stdout_isatty() { 55 | print_pretty_printed_input(input_string, data_format); 56 | std::process::exit(0); 57 | } 58 | 59 | // We use freopen to remap /dev/tty to STDIN so that rustyline works when 60 | // JSON input is provided via STDIN. rustyline gets initialized when we 61 | // create the App, so by putting this before creating the app, we make 62 | // sure rustyline gets the /dev/tty input. 63 | input::remap_dev_tty_to_stdin(); 64 | 65 | let stdout = Box::new(MouseTerminal::from(HideCursor::from( 66 | AlternateScreen::from(io::stdout()), 67 | ))) as Box; 68 | let raw_stdout = stdout.into_raw_mode().unwrap(); 69 | 70 | let mut app = match App::new(&opt, input_string, data_format, input_filename, raw_stdout) { 71 | Ok(jl) => jl, 72 | Err(err) => { 73 | eprintln!("{err}"); 74 | std::process::exit(1); 75 | } 76 | }; 77 | 78 | app.run(Box::new(input::get_input())); 79 | } 80 | 81 | fn print_pretty_printed_input(input: String, data_format: DataFormat) { 82 | // Don't try to pretty print YAML input; just pass it through. 83 | if data_format == DataFormat::Yaml { 84 | print!("{input}"); 85 | return; 86 | } 87 | 88 | let flatjson = match flatjson::parse_top_level_json(input) { 89 | Ok(flatjson) => flatjson, 90 | Err(err) => { 91 | eprintln!("Unable to parse input: {err:?}"); 92 | std::process::exit(1); 93 | } 94 | }; 95 | 96 | print!("{}", flatjson.pretty_printed()); 97 | } 98 | 99 | fn get_input_and_filename(opt: &Opt) -> io::Result<(String, String)> { 100 | let mut input_string = String::new(); 101 | let filename; 102 | 103 | match &opt.input { 104 | None => { 105 | if isatty::stdin_isatty() { 106 | println!("Missing filename (\"jless --help\" for help)"); 107 | std::process::exit(1); 108 | } 109 | filename = "STDIN".to_string(); 110 | io::stdin().read_to_string(&mut input_string)?; 111 | } 112 | Some(path) => { 113 | if *path == PathBuf::from("-") { 114 | filename = "STDIN".to_string(); 115 | io::stdin().read_to_string(&mut input_string)?; 116 | } else { 117 | File::open(path)?.read_to_string(&mut input_string)?; 118 | filename = String::from(path.file_name().unwrap().to_string_lossy()); 119 | } 120 | } 121 | } 122 | 123 | Ok((input_string, filename)) 124 | } 125 | 126 | fn determine_data_format(format: Option, filename: &str) -> DataFormat { 127 | format.unwrap_or_else(|| { 128 | match std::path::Path::new(filename) 129 | .extension() 130 | .and_then(std::ffi::OsStr::to_str) 131 | { 132 | Some("yml") | Some("yaml") => DataFormat::Yaml, 133 | _ => DataFormat::Json, 134 | } 135 | }) 136 | } 137 | -------------------------------------------------------------------------------- /src/options.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use clap::{ArgAction, Parser, ValueEnum}; 4 | 5 | use crate::viewer::Mode; 6 | 7 | #[derive(PartialEq, Eq, Copy, Clone, Debug, ValueEnum)] 8 | pub enum DataFormat { 9 | Json, 10 | Yaml, 11 | } 12 | 13 | /// A pager for JSON (or YAML) data 14 | #[derive(Debug, Parser)] 15 | #[command(name = "jless", version)] 16 | pub struct Opt { 17 | /// Input file. jless will read from stdin if no input file is 18 | /// provided, or '-' is specified. If a filename is provided, jless 19 | /// will check the extension to determine what the input format is, 20 | /// and by default will assume JSON. Can specify input format 21 | /// explicitly using --json or --yaml. 22 | pub input: Option, 23 | 24 | /// Initial viewing mode. In line mode (--mode line), opening 25 | /// and closing curly and square brackets are shown and all 26 | /// Object keys are quoted. In data mode (--mode data; the default), 27 | /// closing braces, commas, and quotes around Object keys are elided. 28 | /// The active mode can be toggled by pressing 'm'. 29 | #[arg(short, long, value_enum, hide_possible_values = true, default_value_t = Mode::Data)] 30 | pub mode: Mode, 31 | 32 | // This godforsaken configuration to get both --line-numbers and --no-line-numbers to 33 | // work (with --line-numbers as the default) and --relative-line-numbers and 34 | // --no-relative-line-numbers to work (with --no-relative-line-numbers as the default) 35 | // was taken from here: 36 | // 37 | // https://jwodder.github.io/kbits/posts/clap-bool-negate/ 38 | /// Don't show line numbers. 39 | #[arg(short = 'N', long = "no-line-numbers", action = ArgAction::SetFalse)] 40 | pub show_line_numbers: bool, 41 | 42 | /// Show "line" numbers (default). Line numbers are determined by 43 | /// the line number of a given line if the document were pretty printed. 44 | /// These means there are discontinuities when viewing in data mode 45 | /// because the lines containing closing brackets and braces aren't displayed. 46 | #[arg( 47 | short = 'n', 48 | long = "line-numbers", 49 | overrides_with = "show_line_numbers" 50 | )] 51 | pub _show_line_numbers_hidden: bool, 52 | 53 | /// Show the line number relative to the currently focused line. Relative line 54 | /// numbers help you use a count with vertical motion commands (j k) without 55 | /// having to count. 56 | #[arg( 57 | short = 'r', 58 | long = "relative-line-numbers", 59 | overrides_with = "_show_relative_line_numbers_hidden" 60 | )] 61 | pub show_relative_line_numbers: bool, 62 | 63 | /// Don't show relative line numbers (default). 64 | #[arg(short = 'R', long = "no-relative-line-numbers")] 65 | _show_relative_line_numbers_hidden: bool, 66 | 67 | /// Number of lines to maintain as padding between the currently 68 | /// focused row and the top or bottom of the screen. Setting this to 69 | /// a large value will keep the focused in the middle of the screen 70 | /// (except at the start or end of a file). 71 | #[arg(long = "scrolloff", default_value_t = 3)] 72 | pub scrolloff: u16, 73 | 74 | /// Parse input as JSON, regardless of file extension. 75 | #[arg(long = "json", group = "data-format", display_order = 1000)] 76 | pub json: bool, 77 | 78 | /// Parse input as YAML, regardless of file extension. 79 | #[arg(long = "yaml", group = "data-format", display_order = 1000)] 80 | pub yaml: bool, 81 | } 82 | 83 | impl Opt { 84 | pub fn data_format(&self) -> Option { 85 | if self.json { 86 | Some(DataFormat::Json) 87 | } else if self.yaml { 88 | Some(DataFormat::Yaml) 89 | } else { 90 | None 91 | } 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/render-notes.md: -------------------------------------------------------------------------------- 1 | # Notes: 2 | 3 | - Want to enable "scroll off", which means selection will 4 | never get with N lines of the top or bottom of the screen. 5 | - When jumping to an element that's above the top scroll-off, 6 | that element should then be focused at line N. 7 | - When hitting down arrow repeatedly, or jumping past the bottom, 8 | the element should appear at line H - N. 9 | 10 | - vim sometimes puts the newly focused element in the middle, but 11 | it's not exactly clear when it does this; perhaps if it's a "big 12 | enough" jump? (How big is enough?) 13 | 14 | - When scrolling with C-e/C-y, focus automatically moves as well 15 | when it gets pushed out of scroll-off zone 16 | 17 | - If implementing something like "collapse me and all my siblings", 18 | then the lines above the currently focused element could change. 19 | 20 | ## Line Wrapping: 21 | - Wow, line wrapping makes this all super hard 22 | - Can detect string widths using `unicode_width` 23 | - How accurate is it? 24 | - For emojis and other things, probably _over_estimates width, which 25 | is good. 26 | - If there's a bug with some crazy characters that's fine 27 | - If just goes one extra line, then just means that scroll off might 28 | be off in places (it'll just push top of the screen off) 29 | - Maybe impossible to see the top of the screen (if implementation 30 | is buggy) 31 | - What about SUPER long strings that fill entire screen. 32 | - I don't care 33 | - Really only makes optimizations that redraw only parts of the screen more difficult 34 | - INITIALLY, won't support wrapping, and will automatically condense 35 | long strings / inlined objects 36 | - Will assume that we won't have super indented objects, or super long 37 | object keys. 38 | 39 | ## How to implement all of this? 40 | 41 | ### First step: 42 | - Support rendering from a certain point, but fill the screen. 43 | - This needs to support starting at a closing brace 44 | - This sort of reference to a starting point is different than a 45 | focus, but maybe just (focus, start_or_end (_or_primitive ?)) 46 | - Similar issue with container state (expanded/inlined/collapsed), 47 | where we only want that state to apply to containers; here we only 48 | want start/end to apply to containers, and not primitives. 49 | - Probably just use a simple bool / 2/3-state enum. 50 | - Maybe primitives also use start? an inlined / collapsed container 51 | is basically a primitive. 52 | 53 | ### Next step: 54 | - When changing focus, figure out what line the focused element will 55 | be on, by starting at "start" point and walking the tree forward until 56 | we find focused element. 57 | - Walking this structure is 'easy' (linear), only need to walk # of lines 58 | in screen, before giving up. 59 | - If focused element isn't on screen, we can work backwards from where we 60 | want it to be to get "start" location of screen 61 | 62 | ### Algorithm: 63 | 64 | 65 | 66 | ## Optimizations: 67 | - It'd be great if we didn't have to print the whole screen each time. 68 | - Actually would it be "great", is printing whole screen each time slow? 69 | -------------------------------------------------------------------------------- /src/screenwriter.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | use std::fmt::Write; 3 | use std::iter::Peekable; 4 | use std::ops::Range; 5 | 6 | use rustyline::Editor; 7 | use termion::raw::RawTerminal; 8 | use unicode_segmentation::UnicodeSegmentation; 9 | use unicode_width::UnicodeWidthStr; 10 | 11 | use crate::app::MAX_BUFFER_SIZE; 12 | use crate::flatjson::{Index, OptionIndex, PathType, Row, Value}; 13 | use crate::lineprinter as lp; 14 | use crate::lineprinter::LineNumber; 15 | use crate::options::Opt; 16 | use crate::search::{MatchRangeIter, SearchState}; 17 | use crate::terminal; 18 | use crate::terminal::{AnsiTerminal, Terminal}; 19 | use crate::truncatedstrview::{TruncatedStrSlice, TruncatedStrView}; 20 | use crate::types::TTYDimensions; 21 | use crate::viewer::{JsonViewer, Mode}; 22 | 23 | pub struct ScreenWriter { 24 | pub stdout: RawTerminal>, 25 | pub command_editor: Editor<()>, 26 | pub dimensions: TTYDimensions, 27 | pub terminal: AnsiTerminal, 28 | 29 | pub show_line_numbers: bool, 30 | pub show_relative_line_numbers: bool, 31 | 32 | indentation_reduction: u16, 33 | truncated_row_value_views: HashMap, 34 | } 35 | 36 | pub enum MessageSeverity { 37 | Info, 38 | Warn, 39 | Error, 40 | } 41 | 42 | impl MessageSeverity { 43 | pub fn color(&self) -> terminal::Color { 44 | match self { 45 | MessageSeverity::Info => terminal::WHITE, 46 | MessageSeverity::Warn => terminal::YELLOW, 47 | MessageSeverity::Error => terminal::RED, 48 | } 49 | } 50 | } 51 | 52 | const TAB_SIZE: isize = 2; 53 | const PATH_BASE: &str = "input"; 54 | const SPACE_BETWEEN_PATH_AND_FILENAME: isize = 3; 55 | 56 | impl ScreenWriter { 57 | pub fn init( 58 | options: &Opt, 59 | stdout: RawTerminal>, 60 | command_editor: Editor<()>, 61 | dimensions: TTYDimensions, 62 | ) -> Self { 63 | ScreenWriter { 64 | stdout, 65 | command_editor, 66 | dimensions, 67 | terminal: AnsiTerminal::new(String::new()), 68 | show_line_numbers: options.show_line_numbers, 69 | show_relative_line_numbers: options.show_relative_line_numbers, 70 | indentation_reduction: 0, 71 | truncated_row_value_views: HashMap::new(), 72 | } 73 | } 74 | 75 | pub fn print( 76 | &mut self, 77 | viewer: &JsonViewer, 78 | input_buffer: &[u8], 79 | input_filename: &str, 80 | search_state: &SearchState, 81 | message: &Option<(String, MessageSeverity)>, 82 | ) { 83 | self.print_viewer(viewer, search_state); 84 | self.print_status_bar(viewer, input_buffer, input_filename, search_state, message); 85 | } 86 | 87 | pub fn print_viewer(&mut self, viewer: &JsonViewer, search_state: &SearchState) { 88 | match self.print_screen_impl(viewer, search_state) { 89 | Ok(_) => match self.terminal.flush_contents(&mut self.stdout) { 90 | Ok(_) => {} 91 | Err(e) => { 92 | eprintln!("Error while printing viewer: {e}"); 93 | } 94 | }, 95 | Err(e) => { 96 | eprintln!("Error while printing viewer: {e}"); 97 | } 98 | } 99 | } 100 | 101 | pub fn print_status_bar( 102 | &mut self, 103 | viewer: &JsonViewer, 104 | input_buffer: &[u8], 105 | input_filename: &str, 106 | search_state: &SearchState, 107 | message: &Option<(String, MessageSeverity)>, 108 | ) { 109 | match self.print_status_bar_impl( 110 | viewer, 111 | input_buffer, 112 | input_filename, 113 | search_state, 114 | message, 115 | ) { 116 | Ok(_) => match self.terminal.flush_contents(&mut self.stdout) { 117 | Ok(_) => {} 118 | Err(e) => { 119 | eprintln!("Error while printing status bar: {e}"); 120 | } 121 | }, 122 | Err(e) => { 123 | eprintln!("Error while printing status bar: {e}"); 124 | } 125 | } 126 | } 127 | 128 | fn print_screen_impl( 129 | &mut self, 130 | viewer: &JsonViewer, 131 | search_state: &SearchState, 132 | ) -> std::fmt::Result { 133 | let mut line = OptionIndex::Index(viewer.top_row); 134 | let mut search_matches = search_state 135 | .matches_iter(viewer.flatjson[line.unwrap()].range.start) 136 | .peekable(); 137 | let current_match = search_state.current_match_range(); 138 | 139 | let mut delta_to_focused_row = viewer.index_of_focused_row_on_screen() as isize; 140 | 141 | for row_index in 0..viewer.dimensions.height { 142 | match line { 143 | OptionIndex::Nil => { 144 | self.terminal.position_cursor(1, row_index + 1)?; 145 | self.terminal.clear_line()?; 146 | self.terminal.set_fg(terminal::LIGHT_BLACK)?; 147 | self.terminal.write_char('~')?; 148 | } 149 | OptionIndex::Index(index) => { 150 | self.print_line( 151 | viewer, 152 | row_index, 153 | index, 154 | delta_to_focused_row, 155 | &mut search_matches, 156 | ¤t_match, 157 | )?; 158 | line = match viewer.mode { 159 | Mode::Line => viewer.flatjson.next_visible_row(index), 160 | Mode::Data => viewer.flatjson.next_item(index), 161 | }; 162 | } 163 | } 164 | 165 | delta_to_focused_row -= 1; 166 | } 167 | 168 | Ok(()) 169 | } 170 | 171 | pub fn get_command(&mut self, prompt: &str) -> rustyline::Result { 172 | write!(self.stdout, "{}", termion::cursor::Show)?; 173 | let _ = self.terminal.position_cursor(1, self.dimensions.height); 174 | self.terminal.flush_contents(&mut self.stdout)?; 175 | 176 | let result = self.command_editor.readline(prompt); 177 | write!(self.stdout, "{}", termion::cursor::Hide)?; 178 | 179 | let _ = self.terminal.position_cursor(1, self.dimensions.height); 180 | let _ = self.terminal.clear_line(); 181 | self.terminal.flush_contents(&mut self.stdout)?; 182 | 183 | result 184 | } 185 | 186 | fn print_line( 187 | &mut self, 188 | viewer: &JsonViewer, 189 | screen_index: u16, 190 | index: Index, 191 | delta_to_focused_row: isize, 192 | search_matches: &mut Peekable, 193 | focused_search_match: &Range, 194 | ) -> std::fmt::Result { 195 | let is_focused = index == viewer.focused_row; 196 | 197 | self.terminal.position_cursor(1, screen_index + 1)?; 198 | self.terminal.clear_line()?; 199 | let row = &viewer.flatjson[index]; 200 | 201 | let indentation_level = 202 | row.depth 203 | .saturating_sub(self.indentation_reduction as usize) as isize; 204 | let indentation = indentation_level * TAB_SIZE; 205 | 206 | let focused = is_focused; 207 | 208 | let mut focused_because_matching_container_pair = false; 209 | if row.is_container() { 210 | let pair_index = row.pair_index().unwrap(); 211 | if is_focused || viewer.focused_row == pair_index { 212 | focused_because_matching_container_pair = true; 213 | } 214 | } 215 | 216 | let mut trailing_comma = false; 217 | 218 | if viewer.mode == Mode::Line { 219 | // The next_sibling field isn't set for CloseContainer rows, so 220 | // we need to get the OpenContainer row before we check if a row 221 | // is the last row in a container, and thus whether we should 222 | // print a trailing comma or not. 223 | let row_root = if row.is_closing_of_container() { 224 | &viewer.flatjson[row.pair_index().unwrap()] 225 | } else { 226 | row 227 | }; 228 | 229 | // Don't print trailing commas after top level elements. 230 | if row_root.parent.is_some() && row_root.next_sibling.is_some() { 231 | if row.is_opening_of_container() && row.is_expanded() { 232 | // Don't print trailing commas after { or [, but 233 | // if it's collapsed, we do print one after the } or ]. 234 | } else { 235 | trailing_comma = true; 236 | } 237 | } 238 | } 239 | 240 | let search_matches_copy = (*search_matches).clone(); 241 | 242 | let mut absolute_line_number = None; 243 | let mut relative_line_number = None; 244 | let max_line_number_width = isize::max( 245 | 2, 246 | isize::ilog10(viewer.flatjson.0.len() as isize + 1) as isize + 1, 247 | ); 248 | 249 | if self.show_line_numbers { 250 | absolute_line_number = Some(index + 1); 251 | } 252 | if self.show_relative_line_numbers { 253 | relative_line_number = Some(delta_to_focused_row.unsigned_abs()); 254 | } 255 | 256 | let mut line = lp::LinePrinter { 257 | mode: viewer.mode, 258 | terminal: &mut self.terminal, 259 | 260 | flatjson: &viewer.flatjson, 261 | row, 262 | line_number: LineNumber { 263 | absolute: absolute_line_number, 264 | relative: relative_line_number, 265 | max_width: max_line_number_width, 266 | }, 267 | 268 | width: self.dimensions.width as isize, 269 | indentation, 270 | 271 | focused, 272 | focused_because_matching_container_pair, 273 | trailing_comma, 274 | 275 | search_matches: Some(search_matches_copy), 276 | focused_search_match, 277 | // This is only used internally and really shouldn't be exposed. 278 | emphasize_focused_search_match: true, 279 | 280 | cached_truncated_value: Some(self.truncated_row_value_views.entry(index)), 281 | }; 282 | 283 | // TODO: Handle error here? Or is never an error because writes 284 | // to String should never fail? 285 | line.print_line().unwrap(); 286 | 287 | *search_matches = line.search_matches.unwrap(); 288 | 289 | Ok(()) 290 | } 291 | 292 | fn line_primitive_value_ref<'a, 'b>( 293 | &'a self, 294 | row: &'a Row, 295 | viewer: &'b JsonViewer, 296 | ) -> Option<&'b str> { 297 | match &row.value { 298 | Value::OpenContainer { .. } | Value::CloseContainer { .. } => None, 299 | _ => { 300 | let range = row.range.clone(); 301 | if let Value::String = &row.value { 302 | Some(&viewer.flatjson.1[range.start + 1..range.end - 1]) 303 | } else { 304 | Some(&viewer.flatjson.1[range]) 305 | } 306 | } 307 | } 308 | } 309 | 310 | fn print_status_bar_impl( 311 | &mut self, 312 | viewer: &JsonViewer, 313 | input_buffer: &[u8], 314 | input_filename: &str, 315 | search_state: &SearchState, 316 | message: &Option<(String, MessageSeverity)>, 317 | ) -> std::fmt::Result { 318 | self.terminal 319 | .position_cursor(1, self.dimensions.height - 1)?; 320 | self.terminal.clear_line()?; 321 | self.terminal.set_style(&terminal::Style { 322 | inverted: true, 323 | ..terminal::Style::default() 324 | })?; 325 | // Need to print a line to ensure the entire bar with the path to 326 | // the node and the filename is highlighted. 327 | for _ in 0..self.dimensions.width { 328 | self.terminal.write_char(' ')?; 329 | } 330 | self.terminal.write_char('\r')?; 331 | 332 | let path_to_node = viewer 333 | .flatjson 334 | .build_path_to_node(PathType::DotWithTopLevelIndex, viewer.focused_row) 335 | .unwrap(); 336 | self.print_path_to_node_and_file_name( 337 | &path_to_node, 338 | input_filename, 339 | viewer.dimensions.width as isize, 340 | )?; 341 | 342 | self.terminal.position_cursor(1, self.dimensions.height)?; 343 | self.terminal.clear_line()?; 344 | 345 | if let Some((contents, severity)) = message { 346 | self.terminal.set_style(&terminal::Style { 347 | fg: severity.color(), 348 | ..terminal::Style::default() 349 | })?; 350 | self.terminal.write_str(contents)?; 351 | } else if search_state.showing_matches() { 352 | self.terminal 353 | .write_char(search_state.direction.prompt_char())?; 354 | self.terminal.write_str(&search_state.search_term)?; 355 | 356 | if let Some((match_num, just_wrapped)) = search_state.active_search_state() { 357 | // Print out which match we're on: 358 | let match_tracker = format!("[{}/{}]", match_num + 1, search_state.num_matches()); 359 | self.terminal.position_cursor( 360 | self.dimensions.width 361 | - (1 + MAX_BUFFER_SIZE as u16) 362 | - (3 + match_tracker.len() as u16 + 3), 363 | self.dimensions.height, 364 | )?; 365 | 366 | let wrapped_char = if just_wrapped { 'W' } else { ' ' }; 367 | write!(self.terminal, " {wrapped_char} {match_tracker}")?; 368 | } 369 | } else { 370 | write!(self.terminal, ":")?; 371 | } 372 | 373 | self.terminal.position_cursor( 374 | // TODO: This can overflow on very skinny screens (2-3 columns). 375 | self.dimensions.width - (1 + MAX_BUFFER_SIZE as u16), 376 | self.dimensions.height, 377 | )?; 378 | self.terminal 379 | .write_str(std::str::from_utf8(input_buffer).unwrap())?; 380 | 381 | // Position the cursor better for random debugging prints. (2 so it's after ':') 382 | self.terminal.position_cursor(2, self.dimensions.height)?; 383 | 384 | Ok(()) 385 | } 386 | 387 | // input.data.viewer.gameDetail.plays[3].playStats[0].gsisPlayer.id filename.> 388 | // input.data.viewer.gameDetail.plays[3].playStats[0].gsisPlayer.id fi> 389 | // // Path also shrinks if needed 390 | // <.data.viewer.gameDetail.plays[3].playStats[0].gsisPlayer.id 391 | fn print_path_to_node_and_file_name( 392 | &mut self, 393 | path_to_node: &str, 394 | filename: &str, 395 | width: isize, 396 | ) -> std::fmt::Result { 397 | let base_len = PATH_BASE.len() as isize; 398 | let path_display_width = UnicodeWidthStr::width(path_to_node) as isize; 399 | let row = self.dimensions.height - 1; 400 | 401 | let space_available_for_filename = 402 | width - base_len - path_display_width - SPACE_BETWEEN_PATH_AND_FILENAME; 403 | let mut space_available_for_base = width - path_display_width; 404 | 405 | let inverted_style = terminal::Style { 406 | inverted: true, 407 | ..terminal::Style::default() 408 | }; 409 | 410 | let truncated_filename = 411 | TruncatedStrView::init_start(filename, space_available_for_filename); 412 | 413 | if truncated_filename.any_contents_visible() { 414 | let filename_width = truncated_filename.used_space().unwrap(); 415 | space_available_for_base -= filename_width - SPACE_BETWEEN_PATH_AND_FILENAME; 416 | } 417 | 418 | let truncated_base = TruncatedStrView::init_back(PATH_BASE, space_available_for_base); 419 | 420 | self.terminal.position_cursor(1, row)?; 421 | self.terminal.set_style(&inverted_style)?; 422 | self.terminal.set_bg(terminal::LIGHT_BLACK)?; 423 | 424 | let base_slice = TruncatedStrSlice { 425 | s: PATH_BASE, 426 | truncated_view: &truncated_base, 427 | }; 428 | 429 | write!(self.terminal, "{base_slice}")?; 430 | 431 | self.terminal.set_bg(terminal::DEFAULT)?; 432 | 433 | // If the path is the exact same width as the screen, we won't print out anything 434 | // for the PATH_BASE, and the path won't be truncated. But there is truncated 435 | // content (the PATH_BASE), so we'll just manually handle this case. 436 | if truncated_base.used_space().is_none() && path_display_width == width { 437 | self.terminal.write_char('…')?; 438 | let mut graphemes = path_to_node.graphemes(true); 439 | // Skip one character. 440 | graphemes.next(); 441 | self.terminal.write_str(graphemes.as_str())?; 442 | } else { 443 | let path_slice = TruncatedStrSlice { 444 | s: path_to_node, 445 | truncated_view: &TruncatedStrView::init_back(path_to_node, width), 446 | }; 447 | 448 | write!(self.terminal, "{path_slice}")?; 449 | } 450 | 451 | if truncated_filename.any_contents_visible() { 452 | let filename_width = truncated_filename.used_space().unwrap(); 453 | 454 | self.terminal 455 | .position_cursor(self.dimensions.width - (filename_width as u16) + 1, row)?; 456 | self.terminal.set_style(&inverted_style)?; 457 | 458 | let truncated_slice = TruncatedStrSlice { 459 | s: filename, 460 | truncated_view: &truncated_filename, 461 | }; 462 | 463 | write!(self.terminal, "{truncated_slice}")?; 464 | } 465 | 466 | Ok(()) 467 | } 468 | 469 | pub fn decrease_indentation_level(&mut self, max_depth: u16) { 470 | self.indentation_reduction = self.indentation_reduction.saturating_add(1).min(max_depth); 471 | } 472 | 473 | pub fn increase_indentation_level(&mut self) { 474 | self.indentation_reduction = self.indentation_reduction.saturating_sub(1) 475 | } 476 | 477 | pub fn scroll_focused_line_right(&mut self, viewer: &JsonViewer, count: usize) { 478 | self.scroll_focused_line(viewer, count, true); 479 | } 480 | 481 | pub fn scroll_focused_line_left(&mut self, viewer: &JsonViewer, count: usize) { 482 | self.scroll_focused_line(viewer, count, false); 483 | } 484 | 485 | pub fn scroll_focused_line(&mut self, viewer: &JsonViewer, count: usize, to_right: bool) { 486 | let row = viewer.focused_row; 487 | let tsv = self.truncated_row_value_views.get(&row); 488 | if let Some(tsv) = tsv { 489 | if tsv.range.is_none() { 490 | return; 491 | } 492 | 493 | // Make tsv not a reference. 494 | let mut tsv = *tsv; 495 | let value_ref = self 496 | .line_primitive_value_ref(&viewer.flatjson[row], viewer) 497 | .unwrap(); 498 | if to_right { 499 | tsv = tsv.scroll_right(value_ref, count); 500 | } else { 501 | tsv = tsv.scroll_left(value_ref, count); 502 | } 503 | self.truncated_row_value_views 504 | .insert(viewer.focused_row, tsv); 505 | } 506 | } 507 | 508 | pub fn scroll_focused_line_to_an_end(&mut self, viewer: &JsonViewer) { 509 | let row = viewer.focused_row; 510 | let tsv = self.truncated_row_value_views.get(&row); 511 | if let Some(tsv) = tsv { 512 | if tsv.range.is_none() { 513 | return; 514 | } 515 | 516 | // Make tsv not a reference. 517 | let mut tsv = *tsv; 518 | let value_ref = self 519 | .line_primitive_value_ref(&viewer.flatjson[row], viewer) 520 | .unwrap(); 521 | tsv = tsv.jump_to_an_end(value_ref); 522 | self.truncated_row_value_views 523 | .insert(viewer.focused_row, tsv); 524 | } 525 | } 526 | 527 | pub fn scroll_line_to_search_match( 528 | &mut self, 529 | viewer: &JsonViewer, 530 | focused_search_range: Range, 531 | ) { 532 | let row = viewer.focused_row; 533 | let tsv = self.truncated_row_value_views.get(&row); 534 | if let Some(tsv) = tsv { 535 | // Make tsv not a reference. 536 | let mut tsv = *tsv; 537 | if tsv.range.is_none() { 538 | return; 539 | } 540 | 541 | let json_row = &viewer.flatjson[row]; 542 | let value_ref = self.line_primitive_value_ref(json_row, viewer).unwrap(); 543 | 544 | let mut range = json_row.range.clone(); 545 | if json_row.is_string() { 546 | range.start += 1; 547 | range.end -= 1; 548 | } 549 | 550 | let no_overlap = 551 | focused_search_range.end <= range.start || range.end <= focused_search_range.start; 552 | if no_overlap { 553 | return; 554 | } 555 | 556 | let mut value_range_start = range.start; 557 | if let Value::String = &json_row.value { 558 | value_range_start += 1; 559 | } 560 | 561 | let offset_focused_range = Range { 562 | start: focused_search_range.start.saturating_sub(value_range_start), 563 | end: focused_search_range.end - value_range_start, 564 | }; 565 | 566 | tsv = tsv.focus(value_ref, &offset_focused_range); 567 | 568 | self.truncated_row_value_views 569 | .insert(viewer.focused_row, tsv); 570 | } 571 | } 572 | } 573 | -------------------------------------------------------------------------------- /src/search.rs: -------------------------------------------------------------------------------- 1 | use std::borrow::Cow; 2 | use std::ops::Range; 3 | 4 | use regex::{Captures, Regex, RegexBuilder}; 5 | 6 | use crate::flatjson::{FlatJson, Index}; 7 | 8 | #[derive(PartialEq, Eq, Debug, Copy, Clone)] 9 | pub enum SearchDirection { 10 | Forward, 11 | Reverse, 12 | } 13 | 14 | impl SearchDirection { 15 | pub fn prompt_char(&self) -> char { 16 | match self { 17 | SearchDirection::Forward => '/', 18 | SearchDirection::Reverse => '?', 19 | } 20 | } 21 | } 22 | 23 | #[derive(PartialEq, Eq, Debug, Copy, Clone)] 24 | pub enum JumpDirection { 25 | Next, 26 | Prev, 27 | } 28 | 29 | pub struct SearchState { 30 | pub direction: SearchDirection, 31 | 32 | pub search_term: String, 33 | 34 | matches: Vec>, 35 | 36 | immediate_state: ImmediateSearchState, 37 | pub ever_searched: bool, 38 | } 39 | 40 | pub enum ImmediateSearchState { 41 | NotSearching, 42 | MatchesVisible, 43 | ActivelySearching { 44 | last_match_jumped_to: usize, 45 | last_search_into_collapsed_container: bool, 46 | just_wrapped: bool, 47 | }, 48 | } 49 | 50 | pub type MatchRangeIter<'a> = std::slice::Iter<'a, Range>; 51 | const STATIC_EMPTY_SLICE: &[Range] = &[]; 52 | 53 | lazy_static::lazy_static! { 54 | static ref SQUARE_AND_CURLY_BRACKETS: Regex = Regex::new(r"(\\\[|\[|\\\]|\]|\\\{|\{|\\\}|\})").unwrap(); 55 | } 56 | 57 | lazy_static::lazy_static! { 58 | static ref UPPER_CASE: Regex = Regex::new("[[:upper:]]").unwrap(); 59 | } 60 | 61 | impl SearchState { 62 | pub fn empty() -> SearchState { 63 | SearchState { 64 | direction: SearchDirection::Forward, 65 | search_term: "".to_owned(), 66 | matches: vec![], 67 | immediate_state: ImmediateSearchState::NotSearching, 68 | ever_searched: false, 69 | } 70 | } 71 | 72 | fn extract_search_term_and_case_sensitivity(search_input: &str) -> (&str, bool) { 73 | let regex_input; 74 | let mut case_sensitive_specified = false; 75 | 76 | if let Some(stripped_of_slash) = search_input.strip_suffix('/') { 77 | regex_input = stripped_of_slash; 78 | } else if let Some(stripped_of_slash_s) = search_input.strip_suffix("/s") { 79 | regex_input = stripped_of_slash_s; 80 | case_sensitive_specified = true; 81 | } else { 82 | regex_input = search_input; 83 | } 84 | 85 | let case_sensitive = if case_sensitive_specified { 86 | true 87 | } else { 88 | UPPER_CASE.is_match(regex_input) 89 | }; 90 | 91 | (regex_input, case_sensitive) 92 | } 93 | 94 | fn invert_square_and_curly_bracket_escaping(regex: &str) -> Cow { 95 | SQUARE_AND_CURLY_BRACKETS.replace_all(regex, |caps: &Captures| match &caps[0] { 96 | "\\[" => "[".to_owned(), 97 | "[" => "\\[".to_owned(), 98 | "\\]" => "]".to_owned(), 99 | "]" => "\\]".to_owned(), 100 | "\\{" => "{".to_owned(), 101 | "{" => "\\{".to_owned(), 102 | "\\}" => "}".to_owned(), 103 | "}" => "\\}".to_owned(), 104 | _ => unreachable!(), 105 | }) 106 | } 107 | 108 | pub fn initialize_search( 109 | search_input: String, 110 | haystack: &str, 111 | direction: SearchDirection, 112 | ) -> Result { 113 | let (regex_input, case_sensitive) = 114 | Self::extract_search_term_and_case_sensitivity(&search_input); 115 | 116 | if regex_input.is_empty() { 117 | return Ok(Self::empty()); 118 | } 119 | 120 | // The default Display implementation for these errors spills 121 | // onto multiple lines. 122 | let inverted = Self::invert_square_and_curly_bracket_escaping(regex_input); 123 | 124 | let regex = RegexBuilder::new(&inverted) 125 | .case_insensitive(!case_sensitive) 126 | .build() 127 | .map_err(|e| format!("{e}").replace('\n', " "))?; 128 | 129 | let matches: Vec> = regex.find_iter(haystack).map(|m| m.range()).collect(); 130 | 131 | Ok(SearchState { 132 | direction, 133 | search_term: regex_input.to_owned(), 134 | matches, 135 | immediate_state: ImmediateSearchState::NotSearching, 136 | ever_searched: true, 137 | }) 138 | } 139 | 140 | pub fn showing_matches(&self) -> bool { 141 | match self.immediate_state { 142 | ImmediateSearchState::NotSearching => false, 143 | ImmediateSearchState::MatchesVisible 144 | | ImmediateSearchState::ActivelySearching { .. } => true, 145 | } 146 | } 147 | 148 | pub fn active_search_state(&self) -> Option<(usize, bool)> { 149 | match self.immediate_state { 150 | ImmediateSearchState::NotSearching | ImmediateSearchState::MatchesVisible => None, 151 | ImmediateSearchState::ActivelySearching { 152 | last_match_jumped_to, 153 | just_wrapped, 154 | .. 155 | } => Some((last_match_jumped_to, just_wrapped)), 156 | } 157 | } 158 | 159 | pub fn num_matches(&self) -> usize { 160 | self.matches.len() 161 | } 162 | 163 | pub fn any_matches(&self) -> bool { 164 | !self.matches.is_empty() 165 | } 166 | 167 | pub fn no_matches_message(&self) -> String { 168 | format!("Pattern not found: {}", self.search_term) 169 | } 170 | 171 | pub fn set_no_longer_actively_searching(&mut self) { 172 | self.immediate_state = ImmediateSearchState::NotSearching; 173 | } 174 | 175 | pub fn set_matches_visible_if_actively_searching(&mut self) { 176 | if let ImmediateSearchState::ActivelySearching { .. } = self.immediate_state { 177 | self.immediate_state = ImmediateSearchState::MatchesVisible; 178 | } 179 | } 180 | 181 | pub fn jump_to_match( 182 | &mut self, 183 | focused_row: Index, 184 | flatjson: &FlatJson, 185 | jump_direction: JumpDirection, 186 | jumps: usize, 187 | ) -> usize { 188 | if self.matches.is_empty() { 189 | panic!("Shouldn't call jump_to_match if no matches"); 190 | } 191 | 192 | let true_direction = self.true_direction(jump_direction); 193 | 194 | let next_match_index = self.get_next_match(focused_row, flatjson, true_direction, jumps); 195 | let row_containing_match = self.compute_destination_row(flatjson, next_match_index); 196 | 197 | // If search takes inside a collapsed object, we will show the first visible ancestor. 198 | let next_focused_row = flatjson.first_visible_ancestor(row_containing_match); 199 | 200 | let wrapped = if focused_row == next_focused_row { 201 | // Usually, if we end up the same place we started, that means that we 202 | // wrapped around because there's only a single (visible) match. 203 | // 204 | // But this can also occur if the opening of a collapsed container matches the 205 | // search term AND the search term appears inside the collapsed container. 206 | // 207 | // We can detect this checking if the next_match_index is different than the 208 | // last_jump_index. 209 | if let Some((last_match_index, _)) = self.active_search_state() { 210 | last_match_index == next_match_index 211 | } else { 212 | true 213 | } 214 | } else { 215 | // Otherwise wrapping depends on which direction we were going. 216 | match true_direction { 217 | SearchDirection::Forward => next_focused_row < focused_row, 218 | SearchDirection::Reverse => next_focused_row > focused_row, 219 | } 220 | }; 221 | 222 | self.immediate_state = ImmediateSearchState::ActivelySearching { 223 | last_match_jumped_to: next_match_index, 224 | // We keep track of whether we searched into an object, so that 225 | // the next time we jump, we can jump past the collapsed container. 226 | last_search_into_collapsed_container: row_containing_match != next_focused_row, 227 | just_wrapped: wrapped, 228 | }; 229 | 230 | next_focused_row 231 | } 232 | 233 | /// Return an iterator over all the stored matches. We pass in a 234 | /// start index that will be used to efficiently skip any matches 235 | /// before that index. 236 | pub fn matches_iter(&self, range_start: usize) -> MatchRangeIter { 237 | match self.immediate_state { 238 | ImmediateSearchState::NotSearching => STATIC_EMPTY_SLICE.iter(), 239 | ImmediateSearchState::MatchesVisible 240 | | ImmediateSearchState::ActivelySearching { .. } => { 241 | let search_result = self 242 | .matches 243 | .binary_search_by(|probe| probe.end.cmp(&range_start)); 244 | let start_index = match search_result { 245 | Ok(i) => i, 246 | Err(i) => i, 247 | }; 248 | self.matches[start_index..].iter() 249 | } 250 | } 251 | } 252 | 253 | /// Returns the range of the currently focused match, or an empty range 254 | /// if not actively searching. 255 | pub fn current_match_range(&self) -> Range { 256 | match self.immediate_state { 257 | ImmediateSearchState::NotSearching | ImmediateSearchState::MatchesVisible => 0..0, 258 | ImmediateSearchState::ActivelySearching { 259 | last_match_jumped_to, 260 | .. 261 | } => self.matches[last_match_jumped_to].clone(), 262 | } 263 | } 264 | 265 | fn true_direction(&self, jump_direction: JumpDirection) -> SearchDirection { 266 | match (self.direction, jump_direction) { 267 | (SearchDirection::Forward, JumpDirection::Next) => SearchDirection::Forward, 268 | (SearchDirection::Forward, JumpDirection::Prev) => SearchDirection::Reverse, 269 | (SearchDirection::Reverse, JumpDirection::Next) => SearchDirection::Reverse, 270 | (SearchDirection::Reverse, JumpDirection::Prev) => SearchDirection::Forward, 271 | } 272 | } 273 | 274 | fn get_next_match( 275 | &mut self, 276 | focused_row: Index, 277 | flatjson: &FlatJson, 278 | true_direction: SearchDirection, 279 | jumps: usize, 280 | ) -> usize { 281 | debug_assert!(jumps != 0); 282 | 283 | match self.immediate_state { 284 | ImmediateSearchState::NotSearching | ImmediateSearchState::MatchesVisible => { 285 | let focused_row_range = flatjson[focused_row].range_represented_by_row(); 286 | 287 | match true_direction { 288 | SearchDirection::Forward => { 289 | // When searching forwards, we want the first match that 290 | // starts _after_ (or equal) the end of focused row. 291 | let next_match = self.matches.partition_point(|match_range| { 292 | match_range.start <= focused_row_range.end 293 | }); 294 | 295 | // If NONE of the matches start after the end of the focused row, 296 | // partition_point returns the length of the array, but then we 297 | // want to jump back to the start in that case. 298 | let next_match_index = if next_match == self.matches.len() { 299 | 0 300 | } else { 301 | next_match 302 | }; 303 | 304 | self.cycle_match(next_match_index, (jumps - 1) as isize) 305 | } 306 | SearchDirection::Reverse => { 307 | // When searching backwards, we want the last match that 308 | // ends before the start of focused row. 309 | let next_match = self.matches.partition_point(|match_range| { 310 | match_range.end < focused_row_range.start 311 | }); 312 | 313 | // If the very first match ends the start of the focused row, 314 | // then partition_point will return 0, and we need to wrap 315 | // around to the end of the file. 316 | // 317 | // But otherwise, partition_point will return the first match 318 | // that didn't end before the start of the focused row, so we 319 | // need to subtract 1. 320 | let next_match_index = if next_match == 0 { 321 | self.matches.len() - 1 322 | } else { 323 | next_match - 1 324 | }; 325 | 326 | self.cycle_match(next_match_index, -((jumps - 1) as isize)) 327 | } 328 | } 329 | } 330 | ImmediateSearchState::ActivelySearching { 331 | last_match_jumped_to, 332 | last_search_into_collapsed_container, 333 | .. 334 | } => { 335 | let delta: isize = match true_direction { 336 | SearchDirection::Forward => jumps as isize, 337 | SearchDirection::Reverse => -(jumps as isize), 338 | }; 339 | 340 | if last_search_into_collapsed_container { 341 | let start_match = last_match_jumped_to; 342 | let mut next_match = self.cycle_match(start_match, delta); 343 | 344 | // Make sure we don't infinitely loop. 345 | while next_match != start_match { 346 | // Convert the next match to a destination row. 347 | let next_destination_row = 348 | self.compute_destination_row(flatjson, next_match); 349 | // Get the first visible ancestor of the next destination 350 | // row, and make sure it isn't the same as the row we're 351 | // currently viewing. If they're different, we've broken 352 | // out of the current collapsed container. 353 | let next_match_visible_ancestor = 354 | flatjson.first_visible_ancestor(next_destination_row); 355 | if next_match_visible_ancestor != focused_row { 356 | break; 357 | } 358 | next_match = self.cycle_match(next_match, delta); 359 | } 360 | 361 | next_match 362 | } else { 363 | self.cycle_match(last_match_jumped_to, delta) 364 | } 365 | } 366 | } 367 | } 368 | 369 | // Helper for modifying a match_index that handles wrapping around the start or end of the 370 | // matches. 371 | fn cycle_match(&self, match_index: usize, delta: isize) -> usize { 372 | ((match_index + self.matches.len()) as isize + delta) as usize % self.matches.len() 373 | } 374 | 375 | fn compute_destination_row(&self, flatjson: &FlatJson, match_index: usize) -> Index { 376 | let match_range = &self.matches[match_index]; // [a, b) 377 | 378 | // We want to jump to the last row that starts before (or at) the start of the match. 379 | flatjson 380 | .0 381 | .partition_point(|row| row.range_represented_by_row().start <= match_range.start) 382 | - 1 383 | } 384 | } 385 | 386 | #[cfg(test)] 387 | mod tests { 388 | use crate::flatjson::parse_top_level_json; 389 | 390 | use super::JumpDirection::*; 391 | use super::SearchDirection::*; 392 | use super::SearchState; 393 | 394 | const SEARCHABLE: &str = r#"{ 395 | "1": "aaa", 396 | "2": [ 397 | "3 bbb", 398 | "4 aaa" 399 | ], 400 | "6": { 401 | "7": "aaa aaa", 402 | "8": "ccc", 403 | "9": "ddd" 404 | }, 405 | "11": "bbb" 406 | }"#; 407 | 408 | #[test] 409 | fn test_extract_search_term_and_case_sensitivity() { 410 | let tests = vec![ 411 | ("abc", ("abc", false)), 412 | ("Abc", ("Abc", true)), 413 | ("abc/", ("abc", false)), 414 | ("abc/s", ("abc", true)), 415 | ("abc/s/", ("abc/s", false)), 416 | ]; 417 | 418 | for (input, search_term_and_case_sensitivity) in tests.into_iter() { 419 | assert_eq!( 420 | search_term_and_case_sensitivity, 421 | SearchState::extract_search_term_and_case_sensitivity(input), 422 | ); 423 | } 424 | } 425 | 426 | #[test] 427 | fn test_invert_square_and_curly_bracket_escaping() { 428 | let tests = vec![ 429 | ("[]", "\\[\\]"), 430 | ("{}", "\\{\\}"), 431 | ("\\[abc\\]", "[abc]"), 432 | ("\\{1,3\\}", "{1,3}"), 433 | ("\\[[]\\]", "[\\[\\]]"), 434 | ]; 435 | 436 | for (before, after) in tests.into_iter() { 437 | assert_eq!( 438 | after, 439 | SearchState::invert_square_and_curly_bracket_escaping(before), 440 | ); 441 | } 442 | } 443 | 444 | #[test] 445 | fn test_basic_search_forward() { 446 | let fj = parse_top_level_json(SEARCHABLE.to_owned()).unwrap(); 447 | let mut search = SearchState::initialize_search("aaa".to_owned(), &fj.1, Forward).unwrap(); 448 | assert_eq!(search.jump_to_match(0, &fj, Next, 1), 1); 449 | assert_eq!(search.jump_to_match(1, &fj, Next, 1), 4); 450 | assert_eq!(search.jump_to_match(4, &fj, Next, 1), 7); 451 | assert_eq!(search.jump_to_match(7, &fj, Next, 1), 7); 452 | assert_wrapped_state(&search, false); 453 | assert_eq!(search.jump_to_match(7, &fj, Next, 1), 1); 454 | assert_wrapped_state(&search, true); 455 | assert_eq!(search.jump_to_match(1, &fj, Prev, 1), 7); 456 | assert_wrapped_state(&search, true); 457 | assert_eq!(search.jump_to_match(7, &fj, Prev, 1), 7); 458 | assert_wrapped_state(&search, false); 459 | assert_eq!(search.jump_to_match(7, &fj, Prev, 1), 4); 460 | assert_eq!(search.jump_to_match(4, &fj, Prev, 1), 1); 461 | assert_eq!(search.jump_to_match(1, &fj, Prev, 1), 7); 462 | 463 | let mut search = SearchState::initialize_search("aaa".to_owned(), &fj.1, Forward).unwrap(); 464 | assert_eq!(search.jump_to_match(0, &fj, Next, 4), 7); 465 | assert_eq!(search.jump_to_match(1, &fj, Next, 2), 4); 466 | assert_eq!(search.jump_to_match(4, &fj, Next, 3), 1); 467 | assert_eq!(search.jump_to_match(1, &fj, Prev, 2), 7); 468 | assert_eq!(search.jump_to_match(7, &fj, Prev, 3), 7); 469 | 470 | assert_eq!(search.jump_to_match(7, &fj, Next, 1), 1); 471 | assert_eq!(search.jump_to_match(1, &fj, Next, 4_000_000_001), 4); 472 | assert_eq!(search.jump_to_match(4, &fj, Prev, 4_000_000_001), 1); 473 | } 474 | 475 | #[test] 476 | fn test_basic_search_backwards() { 477 | let fj = parse_top_level_json(SEARCHABLE.to_owned()).unwrap(); 478 | let mut search = SearchState::initialize_search("aaa".to_owned(), &fj.1, Reverse).unwrap(); 479 | assert_eq!(search.jump_to_match(0, &fj, Next, 1), 7); 480 | assert_wrapped_state(&search, true); 481 | assert_eq!(search.jump_to_match(7, &fj, Next, 1), 7); 482 | assert_eq!(search.jump_to_match(7, &fj, Next, 1), 4); 483 | assert_eq!(search.jump_to_match(4, &fj, Next, 1), 1); 484 | assert_wrapped_state(&search, false); 485 | assert_eq!(search.jump_to_match(1, &fj, Prev, 1), 4); 486 | assert_eq!(search.jump_to_match(4, &fj, Prev, 1), 7); 487 | assert_eq!(search.jump_to_match(7, &fj, Prev, 1), 7); 488 | assert_eq!(search.jump_to_match(7, &fj, Prev, 1), 1); 489 | assert_wrapped_state(&search, true); 490 | assert_eq!(search.jump_to_match(1, &fj, Prev, 1), 4); 491 | assert_wrapped_state(&search, false); 492 | 493 | let mut search = SearchState::initialize_search("aaa".to_owned(), &fj.1, Reverse).unwrap(); 494 | assert_eq!(search.jump_to_match(0, &fj, Next, 4), 1); 495 | assert_eq!(search.jump_to_match(1, &fj, Next, 3), 4); 496 | assert_eq!(search.jump_to_match(4, &fj, Next, 2), 7); 497 | assert_eq!(search.jump_to_match(7, &fj, Prev, 2), 4); 498 | assert_eq!(search.jump_to_match(4, &fj, Prev, 3), 1); 499 | } 500 | 501 | #[test] 502 | fn test_search_collapsed_forward() { 503 | let mut fj = parse_top_level_json(SEARCHABLE.to_owned()).unwrap(); 504 | let mut search = SearchState::initialize_search("aaa".to_owned(), &fj.1, Forward).unwrap(); 505 | fj.collapse(6); 506 | assert_eq!(search.jump_to_match(0, &fj, Next, 1), 1); 507 | assert_eq!(search.jump_to_match(1, &fj, Next, 1), 4); 508 | assert_eq!(search.jump_to_match(4, &fj, Next, 1), 6); 509 | assert_eq!(search.jump_to_match(6, &fj, Next, 1), 1); 510 | assert_eq!(search.jump_to_match(1, &fj, Next, 1), 4); 511 | assert_eq!(search.jump_to_match(4, &fj, Prev, 1), 1); 512 | assert_eq!(search.jump_to_match(1, &fj, Prev, 1), 6); 513 | assert_eq!(search.jump_to_match(6, &fj, Prev, 1), 4); 514 | 515 | let mut search = SearchState::initialize_search("aaa".to_owned(), &fj.1, Forward).unwrap(); 516 | fj.collapse(6); 517 | assert_eq!(search.jump_to_match(0, &fj, Next, 4), 6); 518 | assert_eq!(search.jump_to_match(6, &fj, Next, 1), 1); 519 | assert_eq!(search.jump_to_match(1, &fj, Next, 1), 4); 520 | assert_eq!(search.jump_to_match(4, &fj, Next, 3), 1); 521 | assert_eq!(search.jump_to_match(1, &fj, Prev, 2), 6); 522 | assert_eq!(search.jump_to_match(6, &fj, Prev, 1), 4); 523 | assert_eq!(search.jump_to_match(4, &fj, Prev, 1), 1); 524 | assert_eq!(search.jump_to_match(1, &fj, Prev, 3), 4); 525 | } 526 | 527 | #[test] 528 | fn test_search_collapsed_backwards() { 529 | let mut fj = parse_top_level_json(SEARCHABLE.to_owned()).unwrap(); 530 | let mut search = SearchState::initialize_search("aaa".to_owned(), &fj.1, Reverse).unwrap(); 531 | fj.collapse(6); 532 | assert_eq!(search.jump_to_match(0, &fj, Next, 1), 6); 533 | assert_eq!(search.jump_to_match(6, &fj, Next, 1), 4); 534 | assert_eq!(search.jump_to_match(4, &fj, Next, 1), 1); 535 | assert_eq!(search.jump_to_match(1, &fj, Next, 1), 6); 536 | assert_eq!(search.jump_to_match(6, &fj, Prev, 1), 1); 537 | assert_eq!(search.jump_to_match(1, &fj, Prev, 1), 4); 538 | assert_eq!(search.jump_to_match(4, &fj, Prev, 1), 6); 539 | assert_eq!(search.jump_to_match(6, &fj, Prev, 1), 1); 540 | 541 | let mut search = SearchState::initialize_search("aaa".to_owned(), &fj.1, Reverse).unwrap(); 542 | fj.collapse(6); 543 | assert_eq!(search.jump_to_match(0, &fj, Prev, 4), 6); 544 | assert_eq!(search.jump_to_match(6, &fj, Prev, 1), 1); 545 | assert_eq!(search.jump_to_match(1, &fj, Prev, 1), 4); 546 | assert_eq!(search.jump_to_match(4, &fj, Prev, 3), 1); 547 | assert_eq!(search.jump_to_match(1, &fj, Next, 2), 6); 548 | assert_eq!(search.jump_to_match(6, &fj, Next, 1), 4); 549 | assert_eq!(search.jump_to_match(4, &fj, Next, 1), 1); 550 | assert_eq!(search.jump_to_match(1, &fj, Next, 3), 4); 551 | } 552 | 553 | #[test] 554 | fn test_no_wrap_when_opening_of_collapsed_container_and_contents_match_search() { 555 | const TEST: &str = r#"{ 556 | "term": [ 557 | "term" 558 | ], 559 | "key": "term" 560 | }"#; 561 | let mut fj = parse_top_level_json(TEST.to_owned()).unwrap(); 562 | let mut search = SearchState::initialize_search("term".to_owned(), &fj.1, Forward).unwrap(); 563 | fj.collapse(1); 564 | assert_eq!(search.jump_to_match(0, &fj, Next, 1), 1); 565 | assert_wrapped_state(&search, false); 566 | assert_eq!(search.jump_to_match(1, &fj, Next, 1), 1); 567 | assert_wrapped_state(&search, false); 568 | assert_eq!(search.jump_to_match(1, &fj, Next, 1), 4); 569 | assert_wrapped_state(&search, false); 570 | assert_eq!(search.jump_to_match(4, &fj, Next, 1), 1); 571 | assert_wrapped_state(&search, true); 572 | } 573 | 574 | #[track_caller] 575 | fn assert_wrapped_state(search: &SearchState, expected: bool) { 576 | if let Some((_, wrapped)) = search.active_search_state() { 577 | assert_eq!(wrapped, expected); 578 | } else { 579 | assert!(false, "Not in an active search state"); 580 | } 581 | } 582 | } 583 | -------------------------------------------------------------------------------- /src/terminal.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::{Result, Write}; 2 | 3 | #[derive(Copy, Clone, Debug, Eq, PartialEq)] 4 | pub enum Color { 5 | C16(u8), 6 | Default, 7 | } 8 | 9 | // Commented out colors are unused. 10 | // #[cfg(test)] 11 | // pub const BLACK: Color = Color::C16(0); 12 | pub const RED: Color = Color::C16(1); 13 | pub const GREEN: Color = Color::C16(2); 14 | pub const YELLOW: Color = Color::C16(3); 15 | pub const BLUE: Color = Color::C16(4); 16 | pub const MAGENTA: Color = Color::C16(5); 17 | // pub const CYAN: Color = Color::C16(6); 18 | pub const WHITE: Color = Color::C16(7); 19 | pub const LIGHT_BLACK: Color = Color::C16(8); 20 | // pub const LIGHT_RED: Color = Color::C16(9); 21 | // pub const LIGHT_GREEN: Color = Color::C16(10); 22 | // pub const LIGHT_YELLOW: Color = Color::C16(11); 23 | pub const LIGHT_BLUE: Color = Color::C16(12); 24 | // pub const LIGHT_MAGENTA: Color = Color::C16(13); 25 | // pub const LIGHT_CYAN: Color = Color::C16(14); 26 | // pub const LIGHT_WHITE: Color = Color::C16(15); 27 | pub const DEFAULT: Color = Color::Default; 28 | 29 | #[derive(Copy, Clone)] 30 | pub struct Style { 31 | pub fg: Color, 32 | pub bg: Color, 33 | pub inverted: bool, 34 | pub bold: bool, 35 | pub dimmed: bool, 36 | } 37 | 38 | impl Style { 39 | pub const fn default() -> Self { 40 | Style { 41 | fg: Color::Default, 42 | bg: Color::Default, 43 | inverted: false, 44 | bold: false, 45 | dimmed: false, 46 | } 47 | } 48 | } 49 | 50 | impl Default for Style { 51 | fn default() -> Self { 52 | Style::default() 53 | } 54 | } 55 | 56 | pub trait Terminal: Write { 57 | fn clear_screen(&mut self) -> Result; 58 | fn clear_line(&mut self) -> Result; 59 | 60 | fn position_cursor(&mut self, col: u16, row: u16) -> Result; 61 | fn position_cursor_col(&mut self, col: u16) -> Result; 62 | 63 | fn set_style(&mut self, style: &Style) -> Result; 64 | fn reset_style(&mut self) -> Result; 65 | 66 | fn set_fg(&mut self, color: Color) -> Result; 67 | fn set_bg(&mut self, color: Color) -> Result; 68 | fn set_inverted(&mut self, inverted: bool) -> Result; 69 | fn set_bold(&mut self, bold: bool) -> Result; 70 | fn set_dimmed(&mut self, dimmed: bool) -> Result; 71 | 72 | fn output(&self) -> &str; 73 | 74 | // Only used for testing. 75 | fn clear_output(&mut self); 76 | } 77 | 78 | pub struct AnsiTerminal { 79 | pub output: String, 80 | pub style: Style, 81 | } 82 | 83 | impl AnsiTerminal { 84 | pub fn new(output: String) -> Self { 85 | AnsiTerminal { 86 | output, 87 | style: Style::default(), 88 | } 89 | } 90 | 91 | pub fn flush_contents(&mut self, out: &mut W) -> std::io::Result { 92 | let bytes = out.write(self.output.as_bytes())?; 93 | out.flush()?; 94 | self.output.clear(); 95 | Ok(bytes) 96 | } 97 | } 98 | 99 | impl Write for AnsiTerminal { 100 | fn write_str(&mut self, s: &str) -> Result { 101 | self.output.write_str(s) 102 | } 103 | } 104 | 105 | impl Terminal for AnsiTerminal { 106 | fn clear_screen(&mut self) -> Result { 107 | write!(self, "\x1b[2J") 108 | } 109 | 110 | fn clear_line(&mut self) -> Result { 111 | write!(self, "\x1b[2K") 112 | } 113 | 114 | fn position_cursor(&mut self, col: u16, row: u16) -> Result { 115 | write!(self, "\x1b[{row};{col}H")?; 116 | self.reset_style() 117 | } 118 | 119 | fn position_cursor_col(&mut self, col: u16) -> Result { 120 | write!(self, "\x1b[{col}G")?; 121 | self.reset_style() 122 | } 123 | 124 | fn set_style(&mut self, style: &Style) -> Result { 125 | self.set_fg(style.fg)?; 126 | self.set_bg(style.bg)?; 127 | self.set_inverted(style.inverted)?; 128 | self.set_bold(style.bold)?; 129 | self.set_dimmed(style.dimmed)?; 130 | Ok(()) 131 | } 132 | 133 | fn reset_style(&mut self) -> Result { 134 | self.style = Style::default(); 135 | write!(self, "\x1b[0m") 136 | } 137 | 138 | fn set_fg(&mut self, color: Color) -> Result { 139 | if self.style.fg != color { 140 | match color { 141 | Color::C16(c) => write!(self, "\x1b[38;5;{c}m")?, 142 | Color::Default => write!(self, "\x1b[39m")?, 143 | } 144 | self.style.fg = color; 145 | } 146 | Ok(()) 147 | } 148 | 149 | fn set_bg(&mut self, color: Color) -> Result { 150 | if self.style.bg != color { 151 | match color { 152 | Color::C16(c) => write!(self, "\x1b[48;5;{c}m")?, 153 | Color::Default => write!(self, "\x1b[49m")?, 154 | } 155 | self.style.bg = color; 156 | } 157 | Ok(()) 158 | } 159 | 160 | fn set_inverted(&mut self, inverted: bool) -> Result { 161 | if self.style.inverted != inverted { 162 | if inverted { 163 | write!(self, "\x1b[7m")?; 164 | } else { 165 | write!(self, "\x1b[27m")?; 166 | } 167 | self.style.inverted = inverted; 168 | } 169 | Ok(()) 170 | } 171 | 172 | fn set_bold(&mut self, bold: bool) -> Result { 173 | if self.style.bold != bold { 174 | if bold { 175 | write!(self, "\x1b[1m")?; 176 | } else { 177 | write!(self, "\x1b[22m")?; 178 | // Also resets dimmed, so set that if we need to 179 | if self.style.dimmed { 180 | write!(self, "\x1b[2m")?; 181 | } 182 | } 183 | self.style.bold = bold; 184 | } 185 | Ok(()) 186 | } 187 | 188 | fn set_dimmed(&mut self, dimmed: bool) -> Result { 189 | if self.style.dimmed != dimmed { 190 | if dimmed { 191 | write!(self, "\x1b[2m")?; 192 | } else { 193 | write!(self, "\x1b[22m")?; 194 | // Also resets bold, so set that if we need to 195 | if self.style.bold { 196 | write!(self, "\x1b[1m")?; 197 | } 198 | } 199 | self.style.dimmed = dimmed; 200 | } 201 | Ok(()) 202 | } 203 | 204 | fn output(&self) -> &str { 205 | &self.output 206 | } 207 | 208 | fn clear_output(&mut self) { 209 | self.output.clear() 210 | } 211 | } 212 | 213 | #[cfg(test)] 214 | pub mod test { 215 | use super::*; 216 | 217 | const COLOR_NAMES: [&str; 16] = [ 218 | "Black", 219 | "Red", 220 | "Green", 221 | "Yellow", 222 | "Blue", 223 | "Magenta", 224 | "Cyan", 225 | "White", 226 | "LightBlack", 227 | "LightRed", 228 | "LightGreen", 229 | "LightYellow", 230 | "LightBlue", 231 | "LightMagenta", 232 | "LightCyan", 233 | "LightWhite", 234 | ]; 235 | 236 | impl std::fmt::Display for Color { 237 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 238 | match self { 239 | Color::C16(c) => write!(f, "{}", COLOR_NAMES.get(*c as usize).unwrap_or(&"?")), 240 | Color::Default => write!(f, "Default"), 241 | } 242 | } 243 | } 244 | 245 | pub struct TextOnlyTerminal { 246 | pub output: String, 247 | } 248 | 249 | impl TextOnlyTerminal { 250 | pub fn new() -> Self { 251 | TextOnlyTerminal { 252 | output: String::new(), 253 | } 254 | } 255 | } 256 | 257 | impl Write for TextOnlyTerminal { 258 | fn write_str(&mut self, s: &str) -> Result { 259 | self.output.write_str(s) 260 | } 261 | } 262 | 263 | #[rustfmt::skip] 264 | impl Terminal for TextOnlyTerminal { 265 | fn clear_screen(&mut self) -> Result { Ok(()) } 266 | fn clear_line(&mut self) -> Result { Ok(()) } 267 | fn position_cursor(&mut self, _row: u16, _col: u16) -> Result { Ok(()) } 268 | fn position_cursor_col(&mut self, _col: u16) -> Result { Ok(()) } 269 | fn set_style(&mut self, _style: &Style) -> Result { Ok(()) } 270 | fn reset_style(&mut self) -> Result { Ok(()) } 271 | fn set_fg(&mut self, _color: Color) -> Result { Ok(()) } 272 | fn set_bg(&mut self, _color: Color) -> Result { Ok(()) } 273 | fn set_inverted(&mut self, _inverted: bool) -> Result { Ok(()) } 274 | fn set_bold(&mut self, _bold: bool) -> Result { Ok(()) } 275 | fn set_dimmed(&mut self, _bold: bool) -> Result { Ok(()) } 276 | fn output(&self) -> &str { &self.output } 277 | fn clear_output(&mut self) { self.output.clear() } 278 | } 279 | 280 | pub struct VisibleEscapesTerminal { 281 | pub output: String, 282 | pub style: Style, 283 | pub pending_style: Style, 284 | pub show_position: bool, 285 | pub show_style: bool, 286 | } 287 | 288 | impl VisibleEscapesTerminal { 289 | pub fn new(show_position: bool, show_style: bool) -> Self { 290 | VisibleEscapesTerminal { 291 | output: String::new(), 292 | style: Style::default(), 293 | pending_style: Style::default(), 294 | show_position, 295 | show_style, 296 | } 297 | } 298 | } 299 | 300 | impl VisibleEscapesTerminal { 301 | fn write_pending_styles(&mut self) -> Result { 302 | if self.show_style { 303 | if self.style.fg != self.pending_style.fg { 304 | write!(self.output, "_FG({})_", self.pending_style.fg)?; 305 | } 306 | if self.style.bg != self.pending_style.bg { 307 | write!(self.output, "_BG({})_", self.pending_style.bg)?; 308 | } 309 | if self.style.inverted != self.pending_style.inverted { 310 | if self.pending_style.inverted { 311 | write!(self.output, "_INV_")?; 312 | } else { 313 | write!(self.output, "_!INV_")?; 314 | } 315 | } 316 | if self.style.bold != self.pending_style.bold { 317 | if self.pending_style.bold { 318 | write!(self.output, "_B_")?; 319 | } else { 320 | write!(self.output, "_!B_")?; 321 | } 322 | } 323 | if self.style.dimmed != self.pending_style.dimmed { 324 | if self.pending_style.dimmed { 325 | write!(self.output, "_D_")?; 326 | } else { 327 | write!(self.output, "_!D_")?; 328 | } 329 | } 330 | } 331 | 332 | self.style = self.pending_style; 333 | 334 | Ok(()) 335 | } 336 | } 337 | 338 | impl Write for VisibleEscapesTerminal { 339 | fn write_str(&mut self, s: &str) -> Result { 340 | self.write_pending_styles()?; 341 | self.output.write_str(s) 342 | } 343 | } 344 | 345 | impl Terminal for VisibleEscapesTerminal { 346 | fn clear_screen(&mut self) -> Result { 347 | Ok(()) 348 | } 349 | 350 | fn clear_line(&mut self) -> Result { 351 | Ok(()) 352 | } 353 | 354 | fn position_cursor(&mut self, row: u16, col: u16) -> Result { 355 | if self.show_position { 356 | write!(self, "_RC({row},{col})_") 357 | } else { 358 | Ok(()) 359 | } 360 | } 361 | 362 | fn position_cursor_col(&mut self, col: u16) -> Result { 363 | if self.show_position { 364 | write!(self, "_C({col})_") 365 | } else { 366 | Ok(()) 367 | } 368 | } 369 | 370 | fn set_style(&mut self, style: &Style) -> Result { 371 | self.pending_style = *style; 372 | Ok(()) 373 | } 374 | 375 | fn reset_style(&mut self) -> Result { 376 | self.style = Style::default(); 377 | self.pending_style = Style::default(); 378 | if self.show_style { 379 | write!(self, "_R_")?; 380 | } 381 | Ok(()) 382 | } 383 | 384 | fn set_fg(&mut self, color: Color) -> Result { 385 | self.pending_style.fg = color; 386 | Ok(()) 387 | } 388 | 389 | fn set_bg(&mut self, color: Color) -> Result { 390 | self.pending_style.bg = color; 391 | Ok(()) 392 | } 393 | 394 | fn set_inverted(&mut self, inverted: bool) -> Result { 395 | self.pending_style.inverted = inverted; 396 | Ok(()) 397 | } 398 | 399 | fn set_bold(&mut self, bold: bool) -> Result { 400 | self.pending_style.bold = bold; 401 | Ok(()) 402 | } 403 | 404 | fn set_dimmed(&mut self, dimmed: bool) -> Result { 405 | self.pending_style.dimmed = dimmed; 406 | Ok(()) 407 | } 408 | 409 | fn output(&self) -> &str { 410 | &self.output 411 | } 412 | 413 | fn clear_output(&mut self) { 414 | self.style = Style::default(); 415 | self.pending_style = Style::default(); 416 | self.output.clear() 417 | } 418 | } 419 | } 420 | -------------------------------------------------------------------------------- /src/types.rs: -------------------------------------------------------------------------------- 1 | pub const DEFAULT_WIDTH: u16 = 80; 2 | pub const DEFAULT_HEIGHT: u16 = 24; 3 | pub const STATUS_BAR_HEIGHT: u16 = 2; 4 | 5 | #[derive(Copy, Clone, Debug)] 6 | pub struct TTYDimensions { 7 | pub width: u16, 8 | pub height: u16, 9 | } 10 | 11 | impl TTYDimensions { 12 | pub fn from_size(size: (u16, u16)) -> TTYDimensions { 13 | TTYDimensions { 14 | width: size.0, 15 | height: size.1, 16 | } 17 | } 18 | 19 | pub fn without_status_bar(&self) -> TTYDimensions { 20 | TTYDimensions { 21 | width: self.width, 22 | height: if self.height < STATUS_BAR_HEIGHT { 23 | 0 24 | } else { 25 | self.height - STATUS_BAR_HEIGHT 26 | }, 27 | } 28 | } 29 | } 30 | 31 | impl Default for TTYDimensions { 32 | fn default() -> TTYDimensions { 33 | TTYDimensions { 34 | width: DEFAULT_WIDTH, 35 | height: DEFAULT_HEIGHT, 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/yamlparser.rs: -------------------------------------------------------------------------------- 1 | use yaml_rust::yaml::{Array, Hash, Yaml}; 2 | use yaml_rust::YamlLoader; 3 | 4 | use crate::flatjson::{ContainerType, Index, OptionIndex, Row, Value}; 5 | 6 | struct YamlParser { 7 | parents: Vec, 8 | rows: Vec, 9 | pretty_printed: String, 10 | max_depth: usize, 11 | } 12 | 13 | pub fn parse(yaml: String) -> Result<(Vec, String, usize), String> { 14 | let mut parser = YamlParser { 15 | parents: vec![], 16 | rows: vec![], 17 | pretty_printed: String::new(), 18 | max_depth: 0, 19 | }; 20 | 21 | let docs = match YamlLoader::load_from_str(&yaml) { 22 | Ok(yaml_docs) => yaml_docs, 23 | Err(err) => return Err(format!("{err}")), 24 | }; 25 | 26 | let mut prev_sibling = OptionIndex::Nil; 27 | 28 | for (i, doc) in docs.into_iter().enumerate() { 29 | if i != 0 { 30 | parser.pretty_printed.push('\n'); 31 | } 32 | let index = parser.parse_yaml_item(doc)?; 33 | 34 | parser.rows[index].prev_sibling = prev_sibling; 35 | parser.rows[index].index_in_parent = i; 36 | if let OptionIndex::Index(prev) = prev_sibling { 37 | parser.rows[prev].next_sibling = OptionIndex::Index(index); 38 | } 39 | 40 | prev_sibling = OptionIndex::Index(index); 41 | } 42 | 43 | Ok((parser.rows, parser.pretty_printed, parser.max_depth)) 44 | } 45 | 46 | impl YamlParser { 47 | fn parse_yaml_item(&mut self, item: Yaml) -> Result { 48 | self.max_depth = self.max_depth.max(self.parents.len()); 49 | 50 | let index = match item { 51 | Yaml::BadValue => return Err("Unknown YAML parse error".to_owned()), 52 | Yaml::Null => self.parse_null(), 53 | Yaml::Boolean(b) => self.parse_bool(b), 54 | Yaml::Integer(i) => self.parse_number(i.to_string()), 55 | Yaml::Real(real_str) => self.parse_number(real_str), 56 | Yaml::String(s) => self.parse_string(s), 57 | Yaml::Array(arr) => self.parse_array(arr)?, 58 | Yaml::Hash(hash) => self.parse_hash(hash)?, 59 | // The yaml_rust source says these are not supported yet. 60 | // Aliases are automatically replaced by their anchors, so 61 | // it's unclear what this would be used for. 62 | Yaml::Alias(_) => return Err("YAML parser returned Alias value".to_owned()), 63 | }; 64 | 65 | Ok(index) 66 | } 67 | 68 | fn parse_null(&mut self) -> usize { 69 | let row_index = self.create_row(Value::Null); 70 | self.rows[row_index].range.end = self.rows[row_index].range.start + 4; 71 | self.pretty_printed.push_str("null"); 72 | row_index 73 | } 74 | 75 | fn parse_bool(&mut self, b: bool) -> usize { 76 | let row_index = self.create_row(Value::Boolean); 77 | let (bool_str, len) = if b { ("true", 4) } else { ("false", 5) }; 78 | 79 | self.rows[row_index].range.end = self.rows[row_index].range.start + len; 80 | self.pretty_printed.push_str(bool_str); 81 | 82 | row_index 83 | } 84 | 85 | fn parse_number(&mut self, num_s: String) -> usize { 86 | let row_index = self.create_row(Value::Number); 87 | self.pretty_printed.push_str(&num_s); 88 | 89 | self.rows[row_index].range.end = self.rows[row_index].range.start + num_s.len(); 90 | 91 | row_index 92 | } 93 | 94 | fn parse_string(&mut self, s: String) -> usize { 95 | let row_index = self.create_row(Value::String); 96 | 97 | // Escape newlines. 98 | let s = s.replace('\n', "\\n"); 99 | 100 | self.pretty_printed.push('"'); 101 | self.pretty_printed.push_str(&s); 102 | self.pretty_printed.push('"'); 103 | self.rows[row_index].range.end = self.rows[row_index].range.start + s.len() + 2; 104 | 105 | row_index 106 | } 107 | 108 | fn parse_array(&mut self, arr: Array) -> Result { 109 | if arr.is_empty() { 110 | let row_index = self.create_row(Value::EmptyArray); 111 | self.rows[row_index].range.end = self.rows[row_index].range.start + 2; 112 | self.pretty_printed.push_str("[]"); 113 | return Ok(row_index); 114 | } 115 | 116 | let open_value = Value::OpenContainer { 117 | container_type: ContainerType::Array, 118 | collapsed: false, 119 | // To be set when parsing is complete. 120 | first_child: 0, 121 | close_index: 0, 122 | }; 123 | 124 | let array_open_index = self.create_row(open_value); 125 | 126 | self.parents.push(array_open_index); 127 | self.pretty_printed.push('['); 128 | 129 | let mut prev_sibling = OptionIndex::Nil; 130 | 131 | for (i, child) in arr.into_iter().enumerate() { 132 | if i != 0 { 133 | self.pretty_printed.push_str(", "); 134 | } 135 | 136 | let child_index = self.parse_yaml_item(child)?; 137 | 138 | if i == 0 { 139 | match self.rows[array_open_index].value { 140 | Value::OpenContainer { 141 | ref mut first_child, 142 | .. 143 | } => { 144 | *first_child = child_index; 145 | } 146 | _ => panic!("Must be Array!"), 147 | } 148 | } 149 | 150 | self.rows[child_index].prev_sibling = prev_sibling; 151 | self.rows[child_index].index_in_parent = i; 152 | if let OptionIndex::Index(prev) = prev_sibling { 153 | self.rows[prev].next_sibling = OptionIndex::Index(child_index); 154 | } 155 | 156 | prev_sibling = OptionIndex::Index(child_index); 157 | } 158 | 159 | self.parents.pop(); 160 | 161 | let close_value = Value::CloseContainer { 162 | container_type: ContainerType::Array, 163 | collapsed: false, 164 | last_child: prev_sibling.unwrap(), 165 | open_index: array_open_index, 166 | }; 167 | 168 | let array_close_index = self.create_row(close_value); 169 | 170 | // Update end of the Array range; we add the ']' to pretty_printed 171 | // below, hence the + 1. 172 | self.rows[array_open_index].range.end = self.pretty_printed.len() + 1; 173 | 174 | match self.rows[array_open_index].value { 175 | Value::OpenContainer { 176 | ref mut close_index, 177 | .. 178 | } => { 179 | *close_index = array_close_index; 180 | } 181 | _ => panic!("Must be Array!"), 182 | } 183 | 184 | self.pretty_printed.push(']'); 185 | Ok(array_open_index) 186 | } 187 | 188 | fn parse_hash(&mut self, hash: Hash) -> Result { 189 | if hash.is_empty() { 190 | let row_index = self.create_row(Value::EmptyObject); 191 | self.rows[row_index].range.end = self.rows[row_index].range.start + 2; 192 | self.pretty_printed.push_str("{}"); 193 | return Ok(row_index); 194 | } 195 | 196 | let open_value = Value::OpenContainer { 197 | container_type: ContainerType::Object, 198 | collapsed: false, 199 | // To be set when parsing is complete. 200 | first_child: 0, 201 | close_index: 0, 202 | }; 203 | 204 | let object_open_index = self.create_row(open_value); 205 | 206 | self.parents.push(object_open_index); 207 | self.pretty_printed.push('{'); 208 | 209 | let mut prev_sibling = OptionIndex::Nil; 210 | 211 | for (i, (key, value)) in hash.into_iter().enumerate() { 212 | if i == 0 { 213 | // Add space inside objects. 214 | self.pretty_printed.push(' '); 215 | } else { 216 | self.pretty_printed.push_str(", "); 217 | } 218 | 219 | ///////////////////////////////// 220 | 221 | let key_range = { 222 | let key_range_start = self.pretty_printed.len(); 223 | 224 | self.pretty_print_key_item(key, true)?; 225 | 226 | let key_range_end = self.pretty_printed.len(); 227 | 228 | key_range_start..key_range_end 229 | }; 230 | 231 | self.pretty_printed.push_str(": "); 232 | 233 | let child_index = self.parse_yaml_item(value)?; 234 | 235 | self.rows[child_index].key_range = Some(key_range); 236 | 237 | if i == 0 { 238 | match self.rows[object_open_index].value { 239 | Value::OpenContainer { 240 | ref mut first_child, 241 | .. 242 | } => { 243 | *first_child = child_index; 244 | } 245 | _ => panic!("Must be Object!"), 246 | } 247 | } 248 | 249 | self.rows[child_index].prev_sibling = prev_sibling; 250 | self.rows[child_index].index_in_parent = i; 251 | if let OptionIndex::Index(prev) = prev_sibling { 252 | self.rows[prev].next_sibling = OptionIndex::Index(child_index); 253 | } 254 | 255 | prev_sibling = OptionIndex::Index(child_index); 256 | } 257 | 258 | self.parents.pop(); 259 | 260 | // Print space inside closing brace. 261 | self.pretty_printed.push(' '); 262 | 263 | let close_value = Value::CloseContainer { 264 | container_type: ContainerType::Object, 265 | collapsed: false, 266 | last_child: prev_sibling.unwrap(), 267 | open_index: object_open_index, 268 | }; 269 | 270 | let object_close_index = self.create_row(close_value); 271 | 272 | // Update end of the Object range; we add the '}' to pretty_printed 273 | // below, hence the + 1. 274 | self.rows[object_open_index].range.end = self.pretty_printed.len() + 1; 275 | 276 | match self.rows[object_open_index].value { 277 | Value::OpenContainer { 278 | ref mut close_index, 279 | .. 280 | } => { 281 | *close_index = object_close_index; 282 | } 283 | _ => panic!("Must be Object!"), 284 | } 285 | 286 | self.pretty_printed.push('}'); 287 | Ok(object_open_index) 288 | } 289 | 290 | fn pretty_print_key_item(&mut self, item: Yaml, is_key: bool) -> Result<(), String> { 291 | if let Yaml::String(s) = item { 292 | // Replace newlines. 293 | let s = s.replace('\n', "\\n"); 294 | self.pretty_printed.push('"'); 295 | self.pretty_printed.push_str(&s); 296 | self.pretty_printed.push('"'); 297 | return Ok(()); 298 | } 299 | 300 | if is_key { 301 | self.pretty_printed.push('['); 302 | } 303 | 304 | match item { 305 | Yaml::BadValue => return Err("Unknown YAML parse error".to_owned()), 306 | Yaml::Null => self.pretty_printed.push_str("null"), 307 | Yaml::Boolean(b) => self 308 | .pretty_printed 309 | .push_str(if b { "true" } else { "false " }), 310 | Yaml::Integer(i) => self.pretty_printed.push_str(&i.to_string()), 311 | Yaml::Real(real_str) => self.pretty_printed.push_str(&real_str), 312 | Yaml::Array(arr) => { 313 | if arr.is_empty() { 314 | self.pretty_printed.push_str("[]"); 315 | } else { 316 | self.pretty_printed.push('['); 317 | for (i, elem) in arr.into_iter().enumerate() { 318 | if i != 0 { 319 | self.pretty_printed.push_str(", "); 320 | } 321 | self.pretty_print_key_item(elem, false)?; 322 | } 323 | self.pretty_printed.push(']'); 324 | } 325 | } 326 | Yaml::Hash(hash) => { 327 | if hash.is_empty() { 328 | self.pretty_printed.push_str("{}"); 329 | } else { 330 | self.pretty_printed.push_str("{ "); 331 | for (i, (key, value)) in hash.into_iter().enumerate() { 332 | if i != 0 { 333 | self.pretty_printed.push_str(", "); 334 | } 335 | self.pretty_print_key_item(key, true)?; 336 | self.pretty_printed.push_str(": "); 337 | self.pretty_print_key_item(value, false)?; 338 | } 339 | self.pretty_printed.push_str(" }"); 340 | } 341 | } 342 | // The yaml_rust source says these are not supported yet. 343 | // Aliases are automatically replaced by their anchors, so 344 | // it's unclear what this would be used for. 345 | Yaml::Alias(_) => return Err("YAML parser returned Alias value".to_owned()), 346 | Yaml::String(_) => unreachable!(), 347 | } 348 | 349 | if is_key { 350 | self.pretty_printed.push(']'); 351 | } 352 | 353 | Ok(()) 354 | } 355 | 356 | // Add a new row to the FlatJson representation. 357 | // 358 | // self.pretty_printed should NOT include the added row yet; 359 | // we use the current length of self.pretty_printed as the 360 | // starting index of the row's range. 361 | fn create_row(&mut self, value: Value) -> usize { 362 | let index = self.rows.len(); 363 | 364 | let parent = match self.parents.last() { 365 | None => OptionIndex::Nil, 366 | Some(row_index) => OptionIndex::Index(*row_index), 367 | }; 368 | 369 | let range_start = self.pretty_printed.len(); 370 | 371 | self.rows.push(Row { 372 | // Set correctly by us 373 | parent, 374 | depth: self.parents.len(), 375 | value, 376 | 377 | // The start of this range is set by us, but then we set 378 | // the end when we're done parsing the row. We'll set 379 | // the default end to be one character so we don't have to 380 | // update it after ']' and '}'. 381 | range: range_start..range_start + 1, 382 | 383 | // To be filled in by caller 384 | prev_sibling: OptionIndex::Nil, 385 | next_sibling: OptionIndex::Nil, 386 | index_in_parent: 0, 387 | key_range: None, 388 | }); 389 | 390 | index 391 | } 392 | } 393 | 394 | #[cfg(test)] 395 | mod tests { 396 | use indoc::indoc; 397 | 398 | use super::*; 399 | 400 | #[test] 401 | fn test_basic() { 402 | // 0 2 7 10 15 21 26 32 39 42 403 | // { "a": 1, "b": true, "c": null, "ddd": [] } 404 | let yaml = indoc! {r#" 405 | --- 406 | a: 1 407 | b: true 408 | c: null 409 | ddd: [] 410 | "#} 411 | .to_owned(); 412 | let (rows, _, _) = parse(yaml).unwrap(); 413 | 414 | assert_eq!(rows[0].range, 0..43); // Object 415 | assert_eq!(rows[1].key_range, Some(2..5)); // "a": 1 416 | assert_eq!(rows[1].range, 7..8); // "a": 1 417 | assert_eq!(rows[2].key_range, Some(10..13)); // "b": true 418 | assert_eq!(rows[2].range, 15..19); // "b": true 419 | assert_eq!(rows[3].key_range, Some(21..24)); // "c": null 420 | assert_eq!(rows[3].range, 26..30); // "c": null 421 | assert_eq!(rows[4].range, 39..41); // "ddd": [] 422 | assert_eq!(rows[5].range, 42..43); // } 423 | 424 | // 01 5 14 21 23 425 | // [14, "apple", false, {}] 426 | let yaml = indoc! {r#" 427 | --- 428 | - 14 429 | - apple 430 | - false 431 | - {} 432 | "#} 433 | .to_owned(); 434 | let (rows, _, _) = parse(yaml).unwrap(); 435 | 436 | assert_eq!(rows[0].range, 0..24); // Array 437 | assert_eq!(rows[1].range, 1..3); // 14 438 | assert_eq!(rows[2].range, 5..12); // "apple" 439 | assert_eq!(rows[3].range, 14..19); // false 440 | assert_eq!(rows[4].range, 21..23); // {} 441 | assert_eq!(rows[5].range, 23..24); // ] 442 | 443 | // 01 3 10 17 23 27 32 37 40 46 51 444 | // [{ "abc": "str", "de": 14, "f": null }, true, false] 445 | let yaml = indoc! {r#" 446 | --- 447 | - abc: str 448 | de: 14 449 | f: null 450 | - true 451 | - false 452 | "#} 453 | .to_owned(); 454 | let (rows, _, _) = parse(yaml).unwrap(); 455 | 456 | assert_eq!(rows[0].range, 0..52); // Array 457 | assert_eq!(rows[1].range, 1..38); // Object 458 | assert_eq!(rows[2].key_range, Some(3..8)); // "abc": "str" 459 | assert_eq!(rows[2].range, 10..15); // "abc": "str" 460 | assert_eq!(rows[3].key_range, Some(17..21)); // "de": 14 461 | assert_eq!(rows[3].range, 23..25); // "de": 14 462 | assert_eq!(rows[4].key_range, Some(27..30)); // "f": null 463 | assert_eq!(rows[4].range, 32..36); // "f": null 464 | assert_eq!(rows[5].range, 37..38); // } 465 | assert_eq!(rows[6].range, 40..44); // true 466 | assert_eq!(rows[7].range, 46..51); // false 467 | assert_eq!(rows[8].range, 51..52); // ] 468 | } 469 | 470 | #[test] 471 | fn test_non_scalar_keys() { 472 | let yaml = indoc! {r#" 473 | --- 474 | [1, 2]: 1 475 | { a: 1, b: 2 }: true 476 | "#} 477 | .to_owned(); 478 | // 0 2 1012 15 3537 42 479 | let pretty = r#"{ [[1, 2]]: 1, [{ "a": 1, "b": 2 }]: true }"#; 480 | let (rows, parsed_pretty, _) = parse(yaml).unwrap(); 481 | 482 | assert_eq!(pretty, parsed_pretty); 483 | 484 | assert_eq!(rows[0].range, 0..43); // Object 485 | assert_eq!(rows[1].key_range, Some(2..10)); // [[1, 2]] 486 | assert_eq!(rows[1].range, 12..13); // [[1, 2]]: 1 487 | assert_eq!(rows[2].key_range, Some(15..35)); // [{ "a": 1, "b": 2 }] 488 | assert_eq!(rows[2].range, 37..41); // [{ "a": 1, "b": 2 }]: true 489 | } 490 | 491 | #[test] 492 | fn test_multiline_strings() { 493 | let yaml = indoc! {r#" 494 | --- 495 | str1: 496 | fl 497 | ow 498 | str2: | 499 | a 500 | b 501 | str3: > 502 | fol 503 | ded 504 | ? | 505 | key 506 | string 507 | : 1 508 | "#} 509 | .to_owned(); 510 | let pretty = 511 | r#"{ "str1": "fl ow", "str2": "a\nb\n", "str3": "fol ded\n", "key\nstring\n": 1 }"#; 512 | let (_, parsed_pretty, _) = parse(yaml).unwrap(); 513 | 514 | assert_eq!(pretty, parsed_pretty); 515 | } 516 | } 517 | --------------------------------------------------------------------------------