├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .pre-commit-config.yaml ├── CHANGELOG.md ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── cliff.toml ├── clippy.toml ├── config ├── config.toml.advanced ├── config.toml.default └── config.toml.tiny ├── demo ├── file-explorer.gif ├── populate ├── preview-archive.gif └── record ├── release └── src ├── cli.rs ├── config.rs ├── handler.rs └── main.rs /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | on: [push, pull_request] 2 | 3 | env: 4 | CARGO_TERM_COLOR: always 5 | 6 | jobs: 7 | build: 8 | strategy: 9 | matrix: 10 | os: [ubuntu-latest, macos-latest] 11 | runs-on: ${{ matrix.os }} 12 | steps: 13 | - uses: actions/checkout@v2 14 | - run: cargo build --verbose 15 | 16 | test: 17 | strategy: 18 | matrix: 19 | os: [ubuntu-latest, macos-latest] 20 | runs-on: ${{ matrix.os }} 21 | steps: 22 | - uses: actions/checkout@v2 23 | - run: cargo test --verbose 24 | 25 | clippy: 26 | strategy: 27 | matrix: 28 | os: [ubuntu-latest, macos-latest] 29 | runs-on: ${{ matrix.os }} 30 | steps: 31 | - uses: actions/checkout@v2 32 | - uses: actions-rs/toolchain@v1 33 | with: 34 | profile: minimal 35 | toolchain: stable 36 | override: true 37 | - run: rustup component add clippy 38 | - uses: actions-rs/cargo@v1 39 | with: 40 | command: clippy 41 | args: -- -D warnings 42 | 43 | fmt: 44 | runs-on: ubuntu-latest 45 | steps: 46 | - uses: actions/checkout@v2 47 | - uses: actions-rs/toolchain@v1 48 | with: 49 | profile: minimal 50 | toolchain: stable 51 | override: true 52 | - run: rustup component add rustfmt 53 | - uses: actions-rs/cargo@v1 54 | with: 55 | command: fmt 56 | args: --all -- --check 57 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | /demo/sample.* 3 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # https://pre-commit.com 2 | repos: 3 | - repo: https://github.com/pre-commit/pre-commit-hooks 4 | rev: v5.0.0 5 | hooks: 6 | - id: check-added-large-files 7 | - id: check-case-conflict 8 | - id: check-executables-have-shebangs 9 | - id: check-json 10 | - id: check-merge-conflict 11 | - id: check-symlinks 12 | - id: check-toml 13 | - id: check-vcs-permalinks 14 | - id: check-xml 15 | - id: check-yaml 16 | - id: end-of-file-fixer 17 | - id: fix-byte-order-marker 18 | - id: mixed-line-ending 19 | args: 20 | - --fix=no 21 | - id: trailing-whitespace 22 | args: 23 | - --markdown-linebreak-ext=md 24 | 25 | - repo: https://github.com/doublify/pre-commit-rust 26 | rev: v1.0 27 | hooks: 28 | - id: cargo-check 29 | - id: clippy 30 | - id: fmt 31 | 32 | - repo: https://github.com/shellcheck-py/shellcheck-py 33 | rev: v0.10.0.1 34 | hooks: 35 | - id: shellcheck 36 | 37 | - repo: https://github.com/pre-commit/mirrors-prettier 38 | rev: v2.4.0 39 | hooks: 40 | - id: prettier 41 | args: 42 | - --print-width=120 43 | - --write 44 | stages: [pre-commit] 45 | 46 | - repo: https://github.com/compilerla/conventional-pre-commit 47 | rev: v3.6.0 48 | hooks: 49 | - id: conventional-pre-commit 50 | stages: [commit-msg] 51 | args: [chore, config, doc, refactor, test] 52 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | ## 1.4.2 - 2024-06-07 6 | 7 | ### Bug Fixes 8 | 9 | - Replace buggy/abandoned term size crate 10 | - Shlex deprecation warning 11 | 12 | ### Documentation 13 | 14 | - Rename AUR package 15 | 16 | ### Miscellaneous Tasks 17 | 18 | - Version 1.4.2 19 | 20 | ### Refactor 21 | 22 | - Remove dedicated splice code path 23 | 24 | ## 1.4.1 - 2024-01-19 25 | 26 | ### Bug Fixes 27 | 28 | - Mime iteration for piped data 29 | - Try alternate mode handlers when piped too 30 | 31 | ### Features 32 | 33 | - Update advanced config 34 | 35 | ### Miscellaneous Tasks 36 | 37 | - Version 1.4.1 38 | 39 | ## 1.4.0 - 2023-11-20 40 | 41 | ### Features 42 | 43 | - More general support for MIME prefix match 44 | - Update/improve advanced config 45 | 46 | ### Miscellaneous Tasks 47 | 48 | - Move from structopt to clap 49 | - Version 1.4.0 50 | 51 | ## 1.3.1 - 2023-10-10 52 | 53 | ### Bug Fixes 54 | 55 | - %m substitution sometimes skipped 56 | 57 | ### Miscellaneous Tasks 58 | 59 | - Lint 60 | - Version 1.3.1 61 | 62 | ## 1.3.0 - 2023-04-23 63 | 64 | ### Bug Fixes 65 | 66 | - Build on MacOS 67 | 68 | ### Documentation 69 | 70 | - Fix ranger scope.sh instructions 71 | 72 | ### Features 73 | 74 | - Support edit action 75 | 76 | ### Miscellaneous Tasks 77 | 78 | - Lint 79 | - Lint 80 | - Lint 81 | - Version 1.3.0 82 | 83 | ### Testing 84 | 85 | - Add macos-latest machine for ci test (#4) 86 | 87 | ## 1.2.2 - 2022-10-31 88 | 89 | ### Bug Fixes 90 | 91 | - Archive open handler in advanced config example 92 | - Disable default nix features 93 | 94 | ### Features 95 | 96 | - Update advanced config 97 | - Improve error handling in worker threads 98 | - Update advanced config 99 | - Build with full LTO + strip 100 | - Add RSOP_INPUT_IS_STDIN_COPY·env·var. 101 | 102 | ### Miscellaneous Tasks 103 | 104 | - Lint 105 | - Update dependencies 106 | - Rename release script 107 | - Version 1.2.2 108 | 109 | ## 1.2.1 - 2022-03-30 110 | 111 | ### Bug Fixes 112 | 113 | - Run check/tests in release script 114 | 115 | ### Features 116 | 117 | - Improve reporting of rsi errors 118 | - Update advanced config 119 | - Update advanced config 120 | - Add check for invalid config with no_pipe=false and multiple input patterns 121 | 122 | ### Miscellaneous Tasks 123 | 124 | - Lint 125 | - Version 1.2.1 126 | 127 | ## 1.2.0 - 2022-01-08 128 | 129 | ### Features 130 | 131 | - Support matching by double extensions 132 | - Ensure extension matching is case insensitive 133 | - Update advanced config 134 | 135 | ### Miscellaneous Tasks 136 | 137 | - Version 1.2.0 138 | 139 | ## 1.1.2 - 2021-12-29 140 | 141 | ### Documentation 142 | 143 | - Fix README typo 144 | - Add AUR package link in README 145 | 146 | ### Features 147 | 148 | - Improve error display for common errors 149 | - Improve error display for common errors, take 2 150 | 151 | ### Miscellaneous Tasks 152 | 153 | - Version 1.1.2 154 | 155 | ### Refactor 156 | 157 | - Remove better-panic 158 | 159 | ## 1.1.1 - 2021-12-05 160 | 161 | ### Bug Fixes 162 | 163 | - Mode detection with absolute path 164 | 165 | ### Miscellaneous Tasks 166 | 167 | - Version 1.1.1 168 | 169 | ## 1.1.0 - 2021-09-27 170 | 171 | ### Bug Fixes 172 | 173 | - Add config check for handlers with both 'no_pipe = true' and 'wait = false' 174 | - Incompatible flags in advanced config 175 | 176 | ### Configuration 177 | 178 | - Add application/x-cpio MIME in advanced config + reformat long lists 179 | - Fix some handlers in advanced config when piped 180 | - Add application/x-archive MIME in advanced config 181 | - Fix some more handlers in advanced config when piped 182 | - Add openscad preview in advanced config 183 | - Fix one more handler in advanced config when piped 184 | - Fix remaining handlers in advanced config when piped + remove redundant flags 185 | 186 | ### Documentation 187 | 188 | - Use git-cliff to generate changelog 189 | 190 | ### Features 191 | 192 | - Support file:// url prefix 193 | - Dynamically compute pipe peek size from system page size 194 | - Support %m substitution in command for MIME type 195 | - Add no_pipe option to use temp file if handler does not support reading from stdin 196 | - URL handlers for xdg-open compatibility 197 | 198 | ### Miscellaneous Tasks 199 | 200 | - Version 1.1.0 201 | 202 | ### Refactor 203 | 204 | - Remove duplicate/hardcoded strings in mode handling 205 | - Factorize pattern substitution code 206 | 207 | ### Testing 208 | 209 | - Add tests for default and advanced config 210 | - Test for smallest possible config 211 | 212 | 213 | -------------------------------------------------------------------------------- /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 = "addr2line" 7 | version = "0.24.2" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" 10 | dependencies = [ 11 | "gimli", 12 | ] 13 | 14 | [[package]] 15 | name = "adler2" 16 | version = "2.0.0" 17 | source = "registry+https://github.com/rust-lang/crates.io-index" 18 | checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" 19 | 20 | [[package]] 21 | name = "aho-corasick" 22 | version = "1.1.3" 23 | source = "registry+https://github.com/rust-lang/crates.io-index" 24 | checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" 25 | dependencies = [ 26 | "memchr", 27 | ] 28 | 29 | [[package]] 30 | name = "anstream" 31 | version = "0.6.18" 32 | source = "registry+https://github.com/rust-lang/crates.io-index" 33 | checksum = "8acc5369981196006228e28809f761875c0327210a891e941f4c683b3a99529b" 34 | dependencies = [ 35 | "anstyle", 36 | "anstyle-parse", 37 | "anstyle-query", 38 | "anstyle-wincon", 39 | "colorchoice", 40 | "is_terminal_polyfill", 41 | "utf8parse", 42 | ] 43 | 44 | [[package]] 45 | name = "anstyle" 46 | version = "1.0.10" 47 | source = "registry+https://github.com/rust-lang/crates.io-index" 48 | checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9" 49 | 50 | [[package]] 51 | name = "anstyle-parse" 52 | version = "0.2.6" 53 | source = "registry+https://github.com/rust-lang/crates.io-index" 54 | checksum = "3b2d16507662817a6a20a9ea92df6652ee4f94f914589377d69f3b21bc5798a9" 55 | dependencies = [ 56 | "utf8parse", 57 | ] 58 | 59 | [[package]] 60 | name = "anstyle-query" 61 | version = "1.1.2" 62 | source = "registry+https://github.com/rust-lang/crates.io-index" 63 | checksum = "79947af37f4177cfead1110013d678905c37501914fba0efea834c3fe9a8d60c" 64 | dependencies = [ 65 | "windows-sys 0.59.0", 66 | ] 67 | 68 | [[package]] 69 | name = "anstyle-wincon" 70 | version = "3.0.6" 71 | source = "registry+https://github.com/rust-lang/crates.io-index" 72 | checksum = "2109dbce0e72be3ec00bed26e6a7479ca384ad226efdd66db8fa2e3a38c83125" 73 | dependencies = [ 74 | "anstyle", 75 | "windows-sys 0.59.0", 76 | ] 77 | 78 | [[package]] 79 | name = "anyhow" 80 | version = "1.0.93" 81 | source = "registry+https://github.com/rust-lang/crates.io-index" 82 | checksum = "4c95c10ba0b00a02636238b814946408b1322d5ac4760326e6fb8ec956d85775" 83 | dependencies = [ 84 | "backtrace", 85 | ] 86 | 87 | [[package]] 88 | name = "backtrace" 89 | version = "0.3.74" 90 | source = "registry+https://github.com/rust-lang/crates.io-index" 91 | checksum = "8d82cb332cdfaed17ae235a638438ac4d4839913cc2af585c3c6746e8f8bee1a" 92 | dependencies = [ 93 | "addr2line", 94 | "cfg-if", 95 | "libc", 96 | "miniz_oxide", 97 | "object", 98 | "rustc-demangle", 99 | "windows-targets 0.52.6", 100 | ] 101 | 102 | [[package]] 103 | name = "bitflags" 104 | version = "2.6.0" 105 | source = "registry+https://github.com/rust-lang/crates.io-index" 106 | checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" 107 | 108 | [[package]] 109 | name = "cfg-if" 110 | version = "1.0.0" 111 | source = "registry+https://github.com/rust-lang/crates.io-index" 112 | checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" 113 | 114 | [[package]] 115 | name = "clap" 116 | version = "4.5.20" 117 | source = "registry+https://github.com/rust-lang/crates.io-index" 118 | checksum = "b97f376d85a664d5837dbae44bf546e6477a679ff6610010f17276f686d867e8" 119 | dependencies = [ 120 | "clap_builder", 121 | "clap_derive", 122 | ] 123 | 124 | [[package]] 125 | name = "clap_builder" 126 | version = "4.5.20" 127 | source = "registry+https://github.com/rust-lang/crates.io-index" 128 | checksum = "19bc80abd44e4bed93ca373a0704ccbd1b710dc5749406201bb018272808dc54" 129 | dependencies = [ 130 | "anstream", 131 | "anstyle", 132 | "clap_lex", 133 | "strsim", 134 | ] 135 | 136 | [[package]] 137 | name = "clap_derive" 138 | version = "4.5.18" 139 | source = "registry+https://github.com/rust-lang/crates.io-index" 140 | checksum = "4ac6a0c7b1a9e9a5186361f67dfa1b88213572f427fb9ab038efb2bd8c582dab" 141 | dependencies = [ 142 | "heck", 143 | "proc-macro2", 144 | "quote", 145 | "syn", 146 | ] 147 | 148 | [[package]] 149 | name = "clap_lex" 150 | version = "0.7.2" 151 | source = "registry+https://github.com/rust-lang/crates.io-index" 152 | checksum = "1462739cb27611015575c0c11df5df7601141071f07518d56fcc1be504cbec97" 153 | 154 | [[package]] 155 | name = "colorchoice" 156 | version = "1.0.3" 157 | source = "registry+https://github.com/rust-lang/crates.io-index" 158 | checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" 159 | 160 | [[package]] 161 | name = "colored" 162 | version = "2.1.0" 163 | source = "registry+https://github.com/rust-lang/crates.io-index" 164 | checksum = "cbf2150cce219b664a8a70df7a1f933836724b503f8a413af9365b4dcc4d90b8" 165 | dependencies = [ 166 | "lazy_static", 167 | "windows-sys 0.48.0", 168 | ] 169 | 170 | [[package]] 171 | name = "const_format" 172 | version = "0.2.33" 173 | source = "registry+https://github.com/rust-lang/crates.io-index" 174 | checksum = "50c655d81ff1114fb0dcdea9225ea9f0cc712a6f8d189378e82bdf62a473a64b" 175 | dependencies = [ 176 | "const_format_proc_macros", 177 | ] 178 | 179 | [[package]] 180 | name = "const_format_proc_macros" 181 | version = "0.2.33" 182 | source = "registry+https://github.com/rust-lang/crates.io-index" 183 | checksum = "eff1a44b93f47b1bac19a27932f5c591e43d1ba357ee4f61526c8a25603f0eb1" 184 | dependencies = [ 185 | "proc-macro2", 186 | "quote", 187 | "unicode-xid", 188 | ] 189 | 190 | [[package]] 191 | name = "crossbeam-utils" 192 | version = "0.8.20" 193 | source = "registry+https://github.com/rust-lang/crates.io-index" 194 | checksum = "22ec99545bb0ed0ea7bb9b8e1e9122ea386ff8a48c0922e43f36d45ab09e0e80" 195 | 196 | [[package]] 197 | name = "displaydoc" 198 | version = "0.2.5" 199 | source = "registry+https://github.com/rust-lang/crates.io-index" 200 | checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" 201 | dependencies = [ 202 | "proc-macro2", 203 | "quote", 204 | "syn", 205 | ] 206 | 207 | [[package]] 208 | name = "equivalent" 209 | version = "1.0.1" 210 | source = "registry+https://github.com/rust-lang/crates.io-index" 211 | checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" 212 | 213 | [[package]] 214 | name = "errno" 215 | version = "0.3.9" 216 | source = "registry+https://github.com/rust-lang/crates.io-index" 217 | checksum = "534c5cf6194dfab3db3242765c03bbe257cf92f22b38f6bc0c58d59108a820ba" 218 | dependencies = [ 219 | "libc", 220 | "windows-sys 0.52.0", 221 | ] 222 | 223 | [[package]] 224 | name = "fastrand" 225 | version = "2.2.0" 226 | source = "registry+https://github.com/rust-lang/crates.io-index" 227 | checksum = "486f806e73c5707928240ddc295403b1b93c96a02038563881c4a2fd84b81ac4" 228 | 229 | [[package]] 230 | name = "fixedbitset" 231 | version = "0.4.2" 232 | source = "registry+https://github.com/rust-lang/crates.io-index" 233 | checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" 234 | 235 | [[package]] 236 | name = "fnv" 237 | version = "1.0.7" 238 | source = "registry+https://github.com/rust-lang/crates.io-index" 239 | checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" 240 | 241 | [[package]] 242 | name = "form_urlencoded" 243 | version = "1.2.1" 244 | source = "registry+https://github.com/rust-lang/crates.io-index" 245 | checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" 246 | dependencies = [ 247 | "percent-encoding", 248 | ] 249 | 250 | [[package]] 251 | name = "gimli" 252 | version = "0.31.1" 253 | source = "registry+https://github.com/rust-lang/crates.io-index" 254 | checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" 255 | 256 | [[package]] 257 | name = "hashbrown" 258 | version = "0.15.1" 259 | source = "registry+https://github.com/rust-lang/crates.io-index" 260 | checksum = "3a9bfc1af68b1726ea47d3d5109de126281def866b33970e10fbab11b5dafab3" 261 | 262 | [[package]] 263 | name = "heck" 264 | version = "0.5.0" 265 | source = "registry+https://github.com/rust-lang/crates.io-index" 266 | checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" 267 | 268 | [[package]] 269 | name = "icu_collections" 270 | version = "1.5.0" 271 | source = "registry+https://github.com/rust-lang/crates.io-index" 272 | checksum = "db2fa452206ebee18c4b5c2274dbf1de17008e874b4dc4f0aea9d01ca79e4526" 273 | dependencies = [ 274 | "displaydoc", 275 | "yoke", 276 | "zerofrom", 277 | "zerovec", 278 | ] 279 | 280 | [[package]] 281 | name = "icu_locid" 282 | version = "1.5.0" 283 | source = "registry+https://github.com/rust-lang/crates.io-index" 284 | checksum = "13acbb8371917fc971be86fc8057c41a64b521c184808a698c02acc242dbf637" 285 | dependencies = [ 286 | "displaydoc", 287 | "litemap", 288 | "tinystr", 289 | "writeable", 290 | "zerovec", 291 | ] 292 | 293 | [[package]] 294 | name = "icu_locid_transform" 295 | version = "1.5.0" 296 | source = "registry+https://github.com/rust-lang/crates.io-index" 297 | checksum = "01d11ac35de8e40fdeda00d9e1e9d92525f3f9d887cdd7aa81d727596788b54e" 298 | dependencies = [ 299 | "displaydoc", 300 | "icu_locid", 301 | "icu_locid_transform_data", 302 | "icu_provider", 303 | "tinystr", 304 | "zerovec", 305 | ] 306 | 307 | [[package]] 308 | name = "icu_locid_transform_data" 309 | version = "1.5.0" 310 | source = "registry+https://github.com/rust-lang/crates.io-index" 311 | checksum = "fdc8ff3388f852bede6b579ad4e978ab004f139284d7b28715f773507b946f6e" 312 | 313 | [[package]] 314 | name = "icu_normalizer" 315 | version = "1.5.0" 316 | source = "registry+https://github.com/rust-lang/crates.io-index" 317 | checksum = "19ce3e0da2ec68599d193c93d088142efd7f9c5d6fc9b803774855747dc6a84f" 318 | dependencies = [ 319 | "displaydoc", 320 | "icu_collections", 321 | "icu_normalizer_data", 322 | "icu_properties", 323 | "icu_provider", 324 | "smallvec", 325 | "utf16_iter", 326 | "utf8_iter", 327 | "write16", 328 | "zerovec", 329 | ] 330 | 331 | [[package]] 332 | name = "icu_normalizer_data" 333 | version = "1.5.0" 334 | source = "registry+https://github.com/rust-lang/crates.io-index" 335 | checksum = "f8cafbf7aa791e9b22bec55a167906f9e1215fd475cd22adfcf660e03e989516" 336 | 337 | [[package]] 338 | name = "icu_properties" 339 | version = "1.5.1" 340 | source = "registry+https://github.com/rust-lang/crates.io-index" 341 | checksum = "93d6020766cfc6302c15dbbc9c8778c37e62c14427cb7f6e601d849e092aeef5" 342 | dependencies = [ 343 | "displaydoc", 344 | "icu_collections", 345 | "icu_locid_transform", 346 | "icu_properties_data", 347 | "icu_provider", 348 | "tinystr", 349 | "zerovec", 350 | ] 351 | 352 | [[package]] 353 | name = "icu_properties_data" 354 | version = "1.5.0" 355 | source = "registry+https://github.com/rust-lang/crates.io-index" 356 | checksum = "67a8effbc3dd3e4ba1afa8ad918d5684b8868b3b26500753effea8d2eed19569" 357 | 358 | [[package]] 359 | name = "icu_provider" 360 | version = "1.5.0" 361 | source = "registry+https://github.com/rust-lang/crates.io-index" 362 | checksum = "6ed421c8a8ef78d3e2dbc98a973be2f3770cb42b606e3ab18d6237c4dfde68d9" 363 | dependencies = [ 364 | "displaydoc", 365 | "icu_locid", 366 | "icu_provider_macros", 367 | "stable_deref_trait", 368 | "tinystr", 369 | "writeable", 370 | "yoke", 371 | "zerofrom", 372 | "zerovec", 373 | ] 374 | 375 | [[package]] 376 | name = "icu_provider_macros" 377 | version = "1.5.0" 378 | source = "registry+https://github.com/rust-lang/crates.io-index" 379 | checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6" 380 | dependencies = [ 381 | "proc-macro2", 382 | "quote", 383 | "syn", 384 | ] 385 | 386 | [[package]] 387 | name = "idna" 388 | version = "1.0.3" 389 | source = "registry+https://github.com/rust-lang/crates.io-index" 390 | checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" 391 | dependencies = [ 392 | "idna_adapter", 393 | "smallvec", 394 | "utf8_iter", 395 | ] 396 | 397 | [[package]] 398 | name = "idna_adapter" 399 | version = "1.2.0" 400 | source = "registry+https://github.com/rust-lang/crates.io-index" 401 | checksum = "daca1df1c957320b2cf139ac61e7bd64fed304c5040df000a745aa1de3b4ef71" 402 | dependencies = [ 403 | "icu_normalizer", 404 | "icu_properties", 405 | ] 406 | 407 | [[package]] 408 | name = "indexmap" 409 | version = "2.6.0" 410 | source = "registry+https://github.com/rust-lang/crates.io-index" 411 | checksum = "707907fe3c25f5424cce2cb7e1cbcafee6bdbe735ca90ef77c29e84591e5b9da" 412 | dependencies = [ 413 | "equivalent", 414 | "hashbrown", 415 | ] 416 | 417 | [[package]] 418 | name = "is_terminal_polyfill" 419 | version = "1.70.1" 420 | source = "registry+https://github.com/rust-lang/crates.io-index" 421 | checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" 422 | 423 | [[package]] 424 | name = "lazy_static" 425 | version = "1.5.0" 426 | source = "registry+https://github.com/rust-lang/crates.io-index" 427 | checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" 428 | 429 | [[package]] 430 | name = "libc" 431 | version = "0.2.162" 432 | source = "registry+https://github.com/rust-lang/crates.io-index" 433 | checksum = "18d287de67fe55fd7e1581fe933d965a5a9477b38e949cfa9f8574ef01506398" 434 | 435 | [[package]] 436 | name = "libredox" 437 | version = "0.1.3" 438 | source = "registry+https://github.com/rust-lang/crates.io-index" 439 | checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" 440 | dependencies = [ 441 | "bitflags", 442 | "libc", 443 | "redox_syscall", 444 | ] 445 | 446 | [[package]] 447 | name = "linux-raw-sys" 448 | version = "0.4.14" 449 | source = "registry+https://github.com/rust-lang/crates.io-index" 450 | checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89" 451 | 452 | [[package]] 453 | name = "litemap" 454 | version = "0.7.3" 455 | source = "registry+https://github.com/rust-lang/crates.io-index" 456 | checksum = "643cb0b8d4fcc284004d5fd0d67ccf61dfffadb7f75e1e71bc420f4688a3a704" 457 | 458 | [[package]] 459 | name = "log" 460 | version = "0.4.22" 461 | source = "registry+https://github.com/rust-lang/crates.io-index" 462 | checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" 463 | 464 | [[package]] 465 | name = "memchr" 466 | version = "2.7.4" 467 | source = "registry+https://github.com/rust-lang/crates.io-index" 468 | checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" 469 | 470 | [[package]] 471 | name = "minimal-lexical" 472 | version = "0.2.1" 473 | source = "registry+https://github.com/rust-lang/crates.io-index" 474 | checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" 475 | 476 | [[package]] 477 | name = "miniz_oxide" 478 | version = "0.8.0" 479 | source = "registry+https://github.com/rust-lang/crates.io-index" 480 | checksum = "e2d80299ef12ff69b16a84bb182e3b9df68b5a91574d3d4fa6e41b65deec4df1" 481 | dependencies = [ 482 | "adler2", 483 | ] 484 | 485 | [[package]] 486 | name = "nom" 487 | version = "7.1.3" 488 | source = "registry+https://github.com/rust-lang/crates.io-index" 489 | checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" 490 | dependencies = [ 491 | "memchr", 492 | "minimal-lexical", 493 | ] 494 | 495 | [[package]] 496 | name = "numtoa" 497 | version = "0.2.4" 498 | source = "registry+https://github.com/rust-lang/crates.io-index" 499 | checksum = "6aa2c4e539b869820a2b82e1aef6ff40aa85e65decdd5185e83fb4b1249cd00f" 500 | 501 | [[package]] 502 | name = "object" 503 | version = "0.36.5" 504 | source = "registry+https://github.com/rust-lang/crates.io-index" 505 | checksum = "aedf0a2d09c573ed1d8d85b30c119153926a2b36dce0ab28322c09a117a4683e" 506 | dependencies = [ 507 | "memchr", 508 | ] 509 | 510 | [[package]] 511 | name = "once_cell" 512 | version = "1.20.2" 513 | source = "registry+https://github.com/rust-lang/crates.io-index" 514 | checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" 515 | 516 | [[package]] 517 | name = "percent-encoding" 518 | version = "2.3.1" 519 | source = "registry+https://github.com/rust-lang/crates.io-index" 520 | checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" 521 | 522 | [[package]] 523 | name = "petgraph" 524 | version = "0.6.5" 525 | source = "registry+https://github.com/rust-lang/crates.io-index" 526 | checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" 527 | dependencies = [ 528 | "fixedbitset", 529 | "indexmap", 530 | ] 531 | 532 | [[package]] 533 | name = "proc-macro2" 534 | version = "1.0.89" 535 | source = "registry+https://github.com/rust-lang/crates.io-index" 536 | checksum = "f139b0662de085916d1fb67d2b4169d1addddda1919e696f3252b740b629986e" 537 | dependencies = [ 538 | "unicode-ident", 539 | ] 540 | 541 | [[package]] 542 | name = "quote" 543 | version = "1.0.37" 544 | source = "registry+https://github.com/rust-lang/crates.io-index" 545 | checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af" 546 | dependencies = [ 547 | "proc-macro2", 548 | ] 549 | 550 | [[package]] 551 | name = "redox_syscall" 552 | version = "0.5.7" 553 | source = "registry+https://github.com/rust-lang/crates.io-index" 554 | checksum = "9b6dfecf2c74bce2466cabf93f6664d6998a69eb21e39f4207930065b27b771f" 555 | dependencies = [ 556 | "bitflags", 557 | ] 558 | 559 | [[package]] 560 | name = "redox_termios" 561 | version = "0.1.3" 562 | source = "registry+https://github.com/rust-lang/crates.io-index" 563 | checksum = "20145670ba436b55d91fc92d25e71160fbfbdd57831631c8d7d36377a476f1cb" 564 | 565 | [[package]] 566 | name = "regex" 567 | version = "1.11.1" 568 | source = "registry+https://github.com/rust-lang/crates.io-index" 569 | checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" 570 | dependencies = [ 571 | "aho-corasick", 572 | "memchr", 573 | "regex-automata", 574 | "regex-syntax", 575 | ] 576 | 577 | [[package]] 578 | name = "regex-automata" 579 | version = "0.4.8" 580 | source = "registry+https://github.com/rust-lang/crates.io-index" 581 | checksum = "368758f23274712b504848e9d5a6f010445cc8b87a7cdb4d7cbee666c1288da3" 582 | dependencies = [ 583 | "aho-corasick", 584 | "memchr", 585 | "regex-syntax", 586 | ] 587 | 588 | [[package]] 589 | name = "regex-syntax" 590 | version = "0.8.5" 591 | source = "registry+https://github.com/rust-lang/crates.io-index" 592 | checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" 593 | 594 | [[package]] 595 | name = "rsop" 596 | version = "1.4.2" 597 | dependencies = [ 598 | "anyhow", 599 | "clap", 600 | "const_format", 601 | "crossbeam-utils", 602 | "log", 603 | "regex", 604 | "serde", 605 | "shlex", 606 | "simple_logger", 607 | "strum", 608 | "tempfile", 609 | "termion", 610 | "thiserror", 611 | "toml", 612 | "tree_magic_mini", 613 | "url", 614 | "xdg", 615 | ] 616 | 617 | [[package]] 618 | name = "rustc-demangle" 619 | version = "0.1.24" 620 | source = "registry+https://github.com/rust-lang/crates.io-index" 621 | checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" 622 | 623 | [[package]] 624 | name = "rustix" 625 | version = "0.38.39" 626 | source = "registry+https://github.com/rust-lang/crates.io-index" 627 | checksum = "375116bee2be9ed569afe2154ea6a99dfdffd257f533f187498c2a8f5feaf4ee" 628 | dependencies = [ 629 | "bitflags", 630 | "errno", 631 | "libc", 632 | "linux-raw-sys", 633 | "windows-sys 0.52.0", 634 | ] 635 | 636 | [[package]] 637 | name = "rustversion" 638 | version = "1.0.18" 639 | source = "registry+https://github.com/rust-lang/crates.io-index" 640 | checksum = "0e819f2bc632f285be6d7cd36e25940d45b2391dd6d9b939e79de557f7014248" 641 | 642 | [[package]] 643 | name = "serde" 644 | version = "1.0.214" 645 | source = "registry+https://github.com/rust-lang/crates.io-index" 646 | checksum = "f55c3193aca71c12ad7890f1785d2b73e1b9f63a0bbc353c08ef26fe03fc56b5" 647 | dependencies = [ 648 | "serde_derive", 649 | ] 650 | 651 | [[package]] 652 | name = "serde_derive" 653 | version = "1.0.214" 654 | source = "registry+https://github.com/rust-lang/crates.io-index" 655 | checksum = "de523f781f095e28fa605cdce0f8307e451cc0fd14e2eb4cd2e98a355b147766" 656 | dependencies = [ 657 | "proc-macro2", 658 | "quote", 659 | "syn", 660 | ] 661 | 662 | [[package]] 663 | name = "serde_spanned" 664 | version = "0.6.8" 665 | source = "registry+https://github.com/rust-lang/crates.io-index" 666 | checksum = "87607cb1398ed59d48732e575a4c28a7a8ebf2454b964fe3f224f2afc07909e1" 667 | dependencies = [ 668 | "serde", 669 | ] 670 | 671 | [[package]] 672 | name = "shlex" 673 | version = "1.3.0" 674 | source = "registry+https://github.com/rust-lang/crates.io-index" 675 | checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" 676 | 677 | [[package]] 678 | name = "simple_logger" 679 | version = "5.0.0" 680 | source = "registry+https://github.com/rust-lang/crates.io-index" 681 | checksum = "e8c5dfa5e08767553704aa0ffd9d9794d527103c736aba9854773851fd7497eb" 682 | dependencies = [ 683 | "colored", 684 | "log", 685 | "windows-sys 0.48.0", 686 | ] 687 | 688 | [[package]] 689 | name = "smallvec" 690 | version = "1.13.2" 691 | source = "registry+https://github.com/rust-lang/crates.io-index" 692 | checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" 693 | 694 | [[package]] 695 | name = "stable_deref_trait" 696 | version = "1.2.0" 697 | source = "registry+https://github.com/rust-lang/crates.io-index" 698 | checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" 699 | 700 | [[package]] 701 | name = "strsim" 702 | version = "0.11.1" 703 | source = "registry+https://github.com/rust-lang/crates.io-index" 704 | checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" 705 | 706 | [[package]] 707 | name = "strum" 708 | version = "0.26.3" 709 | source = "registry+https://github.com/rust-lang/crates.io-index" 710 | checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" 711 | dependencies = [ 712 | "strum_macros", 713 | ] 714 | 715 | [[package]] 716 | name = "strum_macros" 717 | version = "0.26.4" 718 | source = "registry+https://github.com/rust-lang/crates.io-index" 719 | checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" 720 | dependencies = [ 721 | "heck", 722 | "proc-macro2", 723 | "quote", 724 | "rustversion", 725 | "syn", 726 | ] 727 | 728 | [[package]] 729 | name = "syn" 730 | version = "2.0.87" 731 | source = "registry+https://github.com/rust-lang/crates.io-index" 732 | checksum = "25aa4ce346d03a6dcd68dd8b4010bcb74e54e62c90c573f394c46eae99aba32d" 733 | dependencies = [ 734 | "proc-macro2", 735 | "quote", 736 | "unicode-ident", 737 | ] 738 | 739 | [[package]] 740 | name = "synstructure" 741 | version = "0.13.1" 742 | source = "registry+https://github.com/rust-lang/crates.io-index" 743 | checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971" 744 | dependencies = [ 745 | "proc-macro2", 746 | "quote", 747 | "syn", 748 | ] 749 | 750 | [[package]] 751 | name = "tempfile" 752 | version = "3.14.0" 753 | source = "registry+https://github.com/rust-lang/crates.io-index" 754 | checksum = "28cce251fcbc87fac86a866eeb0d6c2d536fc16d06f184bb61aeae11aa4cee0c" 755 | dependencies = [ 756 | "cfg-if", 757 | "fastrand", 758 | "once_cell", 759 | "rustix", 760 | "windows-sys 0.59.0", 761 | ] 762 | 763 | [[package]] 764 | name = "termion" 765 | version = "4.0.3" 766 | source = "registry+https://github.com/rust-lang/crates.io-index" 767 | checksum = "7eaa98560e51a2cf4f0bb884d8b2098a9ea11ecf3b7078e9c68242c74cc923a7" 768 | dependencies = [ 769 | "libc", 770 | "libredox", 771 | "numtoa", 772 | "redox_termios", 773 | ] 774 | 775 | [[package]] 776 | name = "thiserror" 777 | version = "2.0.1" 778 | source = "registry+https://github.com/rust-lang/crates.io-index" 779 | checksum = "07c1e40dd48a282ae8edc36c732cbc219144b87fb6a4c7316d611c6b1f06ec0c" 780 | dependencies = [ 781 | "thiserror-impl", 782 | ] 783 | 784 | [[package]] 785 | name = "thiserror-impl" 786 | version = "2.0.1" 787 | source = "registry+https://github.com/rust-lang/crates.io-index" 788 | checksum = "874aa7e446f1da8d9c3a5c95b1c5eb41d800045252121dc7f8e0ba370cee55f5" 789 | dependencies = [ 790 | "proc-macro2", 791 | "quote", 792 | "syn", 793 | ] 794 | 795 | [[package]] 796 | name = "tinystr" 797 | version = "0.7.6" 798 | source = "registry+https://github.com/rust-lang/crates.io-index" 799 | checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f" 800 | dependencies = [ 801 | "displaydoc", 802 | "zerovec", 803 | ] 804 | 805 | [[package]] 806 | name = "toml" 807 | version = "0.8.19" 808 | source = "registry+https://github.com/rust-lang/crates.io-index" 809 | checksum = "a1ed1f98e3fdc28d6d910e6737ae6ab1a93bf1985935a1193e68f93eeb68d24e" 810 | dependencies = [ 811 | "serde", 812 | "serde_spanned", 813 | "toml_datetime", 814 | "toml_edit", 815 | ] 816 | 817 | [[package]] 818 | name = "toml_datetime" 819 | version = "0.6.8" 820 | source = "registry+https://github.com/rust-lang/crates.io-index" 821 | checksum = "0dd7358ecb8fc2f8d014bf86f6f638ce72ba252a2c3a2572f2a795f1d23efb41" 822 | dependencies = [ 823 | "serde", 824 | ] 825 | 826 | [[package]] 827 | name = "toml_edit" 828 | version = "0.22.22" 829 | source = "registry+https://github.com/rust-lang/crates.io-index" 830 | checksum = "4ae48d6208a266e853d946088ed816055e556cc6028c5e8e2b84d9fa5dd7c7f5" 831 | dependencies = [ 832 | "indexmap", 833 | "serde", 834 | "serde_spanned", 835 | "toml_datetime", 836 | "winnow", 837 | ] 838 | 839 | [[package]] 840 | name = "tree_magic_mini" 841 | version = "3.1.6" 842 | source = "registry+https://github.com/rust-lang/crates.io-index" 843 | checksum = "aac5e8971f245c3389a5a76e648bfc80803ae066a1243a75db0064d7c1129d63" 844 | dependencies = [ 845 | "fnv", 846 | "memchr", 847 | "nom", 848 | "once_cell", 849 | "petgraph", 850 | ] 851 | 852 | [[package]] 853 | name = "unicode-ident" 854 | version = "1.0.13" 855 | source = "registry+https://github.com/rust-lang/crates.io-index" 856 | checksum = "e91b56cd4cadaeb79bbf1a5645f6b4f8dc5bde8834ad5894a8db35fda9efa1fe" 857 | 858 | [[package]] 859 | name = "unicode-xid" 860 | version = "0.2.6" 861 | source = "registry+https://github.com/rust-lang/crates.io-index" 862 | checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" 863 | 864 | [[package]] 865 | name = "url" 866 | version = "2.5.3" 867 | source = "registry+https://github.com/rust-lang/crates.io-index" 868 | checksum = "8d157f1b96d14500ffdc1f10ba712e780825526c03d9a49b4d0324b0d9113ada" 869 | dependencies = [ 870 | "form_urlencoded", 871 | "idna", 872 | "percent-encoding", 873 | ] 874 | 875 | [[package]] 876 | name = "utf16_iter" 877 | version = "1.0.5" 878 | source = "registry+https://github.com/rust-lang/crates.io-index" 879 | checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" 880 | 881 | [[package]] 882 | name = "utf8_iter" 883 | version = "1.0.4" 884 | source = "registry+https://github.com/rust-lang/crates.io-index" 885 | checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" 886 | 887 | [[package]] 888 | name = "utf8parse" 889 | version = "0.2.2" 890 | source = "registry+https://github.com/rust-lang/crates.io-index" 891 | checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" 892 | 893 | [[package]] 894 | name = "windows-sys" 895 | version = "0.48.0" 896 | source = "registry+https://github.com/rust-lang/crates.io-index" 897 | checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" 898 | dependencies = [ 899 | "windows-targets 0.48.5", 900 | ] 901 | 902 | [[package]] 903 | name = "windows-sys" 904 | version = "0.52.0" 905 | source = "registry+https://github.com/rust-lang/crates.io-index" 906 | checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" 907 | dependencies = [ 908 | "windows-targets 0.52.6", 909 | ] 910 | 911 | [[package]] 912 | name = "windows-sys" 913 | version = "0.59.0" 914 | source = "registry+https://github.com/rust-lang/crates.io-index" 915 | checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" 916 | dependencies = [ 917 | "windows-targets 0.52.6", 918 | ] 919 | 920 | [[package]] 921 | name = "windows-targets" 922 | version = "0.48.5" 923 | source = "registry+https://github.com/rust-lang/crates.io-index" 924 | checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" 925 | dependencies = [ 926 | "windows_aarch64_gnullvm 0.48.5", 927 | "windows_aarch64_msvc 0.48.5", 928 | "windows_i686_gnu 0.48.5", 929 | "windows_i686_msvc 0.48.5", 930 | "windows_x86_64_gnu 0.48.5", 931 | "windows_x86_64_gnullvm 0.48.5", 932 | "windows_x86_64_msvc 0.48.5", 933 | ] 934 | 935 | [[package]] 936 | name = "windows-targets" 937 | version = "0.52.6" 938 | source = "registry+https://github.com/rust-lang/crates.io-index" 939 | checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" 940 | dependencies = [ 941 | "windows_aarch64_gnullvm 0.52.6", 942 | "windows_aarch64_msvc 0.52.6", 943 | "windows_i686_gnu 0.52.6", 944 | "windows_i686_gnullvm", 945 | "windows_i686_msvc 0.52.6", 946 | "windows_x86_64_gnu 0.52.6", 947 | "windows_x86_64_gnullvm 0.52.6", 948 | "windows_x86_64_msvc 0.52.6", 949 | ] 950 | 951 | [[package]] 952 | name = "windows_aarch64_gnullvm" 953 | version = "0.48.5" 954 | source = "registry+https://github.com/rust-lang/crates.io-index" 955 | checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" 956 | 957 | [[package]] 958 | name = "windows_aarch64_gnullvm" 959 | version = "0.52.6" 960 | source = "registry+https://github.com/rust-lang/crates.io-index" 961 | checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" 962 | 963 | [[package]] 964 | name = "windows_aarch64_msvc" 965 | version = "0.48.5" 966 | source = "registry+https://github.com/rust-lang/crates.io-index" 967 | checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" 968 | 969 | [[package]] 970 | name = "windows_aarch64_msvc" 971 | version = "0.52.6" 972 | source = "registry+https://github.com/rust-lang/crates.io-index" 973 | checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" 974 | 975 | [[package]] 976 | name = "windows_i686_gnu" 977 | version = "0.48.5" 978 | source = "registry+https://github.com/rust-lang/crates.io-index" 979 | checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" 980 | 981 | [[package]] 982 | name = "windows_i686_gnu" 983 | version = "0.52.6" 984 | source = "registry+https://github.com/rust-lang/crates.io-index" 985 | checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" 986 | 987 | [[package]] 988 | name = "windows_i686_gnullvm" 989 | version = "0.52.6" 990 | source = "registry+https://github.com/rust-lang/crates.io-index" 991 | checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" 992 | 993 | [[package]] 994 | name = "windows_i686_msvc" 995 | version = "0.48.5" 996 | source = "registry+https://github.com/rust-lang/crates.io-index" 997 | checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" 998 | 999 | [[package]] 1000 | name = "windows_i686_msvc" 1001 | version = "0.52.6" 1002 | source = "registry+https://github.com/rust-lang/crates.io-index" 1003 | checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" 1004 | 1005 | [[package]] 1006 | name = "windows_x86_64_gnu" 1007 | version = "0.48.5" 1008 | source = "registry+https://github.com/rust-lang/crates.io-index" 1009 | checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" 1010 | 1011 | [[package]] 1012 | name = "windows_x86_64_gnu" 1013 | version = "0.52.6" 1014 | source = "registry+https://github.com/rust-lang/crates.io-index" 1015 | checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" 1016 | 1017 | [[package]] 1018 | name = "windows_x86_64_gnullvm" 1019 | version = "0.48.5" 1020 | source = "registry+https://github.com/rust-lang/crates.io-index" 1021 | checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" 1022 | 1023 | [[package]] 1024 | name = "windows_x86_64_gnullvm" 1025 | version = "0.52.6" 1026 | source = "registry+https://github.com/rust-lang/crates.io-index" 1027 | checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" 1028 | 1029 | [[package]] 1030 | name = "windows_x86_64_msvc" 1031 | version = "0.48.5" 1032 | source = "registry+https://github.com/rust-lang/crates.io-index" 1033 | checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" 1034 | 1035 | [[package]] 1036 | name = "windows_x86_64_msvc" 1037 | version = "0.52.6" 1038 | source = "registry+https://github.com/rust-lang/crates.io-index" 1039 | checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" 1040 | 1041 | [[package]] 1042 | name = "winnow" 1043 | version = "0.6.20" 1044 | source = "registry+https://github.com/rust-lang/crates.io-index" 1045 | checksum = "36c1fec1a2bb5866f07c25f68c26e565c4c200aebb96d7e55710c19d3e8ac49b" 1046 | dependencies = [ 1047 | "memchr", 1048 | ] 1049 | 1050 | [[package]] 1051 | name = "write16" 1052 | version = "1.0.0" 1053 | source = "registry+https://github.com/rust-lang/crates.io-index" 1054 | checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" 1055 | 1056 | [[package]] 1057 | name = "writeable" 1058 | version = "0.5.5" 1059 | source = "registry+https://github.com/rust-lang/crates.io-index" 1060 | checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" 1061 | 1062 | [[package]] 1063 | name = "xdg" 1064 | version = "2.5.2" 1065 | source = "registry+https://github.com/rust-lang/crates.io-index" 1066 | checksum = "213b7324336b53d2414b2db8537e56544d981803139155afa84f76eeebb7a546" 1067 | 1068 | [[package]] 1069 | name = "yoke" 1070 | version = "0.7.4" 1071 | source = "registry+https://github.com/rust-lang/crates.io-index" 1072 | checksum = "6c5b1314b079b0930c31e3af543d8ee1757b1951ae1e1565ec704403a7240ca5" 1073 | dependencies = [ 1074 | "serde", 1075 | "stable_deref_trait", 1076 | "yoke-derive", 1077 | "zerofrom", 1078 | ] 1079 | 1080 | [[package]] 1081 | name = "yoke-derive" 1082 | version = "0.7.4" 1083 | source = "registry+https://github.com/rust-lang/crates.io-index" 1084 | checksum = "28cc31741b18cb6f1d5ff12f5b7523e3d6eb0852bbbad19d73905511d9849b95" 1085 | dependencies = [ 1086 | "proc-macro2", 1087 | "quote", 1088 | "syn", 1089 | "synstructure", 1090 | ] 1091 | 1092 | [[package]] 1093 | name = "zerofrom" 1094 | version = "0.1.4" 1095 | source = "registry+https://github.com/rust-lang/crates.io-index" 1096 | checksum = "91ec111ce797d0e0784a1116d0ddcdbea84322cd79e5d5ad173daeba4f93ab55" 1097 | dependencies = [ 1098 | "zerofrom-derive", 1099 | ] 1100 | 1101 | [[package]] 1102 | name = "zerofrom-derive" 1103 | version = "0.1.4" 1104 | source = "registry+https://github.com/rust-lang/crates.io-index" 1105 | checksum = "0ea7b4a3637ea8669cedf0f1fd5c286a17f3de97b8dd5a70a6c167a1730e63a5" 1106 | dependencies = [ 1107 | "proc-macro2", 1108 | "quote", 1109 | "syn", 1110 | "synstructure", 1111 | ] 1112 | 1113 | [[package]] 1114 | name = "zerovec" 1115 | version = "0.10.4" 1116 | source = "registry+https://github.com/rust-lang/crates.io-index" 1117 | checksum = "aa2b893d79df23bfb12d5461018d408ea19dfafe76c2c7ef6d4eba614f8ff079" 1118 | dependencies = [ 1119 | "yoke", 1120 | "zerofrom", 1121 | "zerovec-derive", 1122 | ] 1123 | 1124 | [[package]] 1125 | name = "zerovec-derive" 1126 | version = "0.10.3" 1127 | source = "registry+https://github.com/rust-lang/crates.io-index" 1128 | checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6" 1129 | dependencies = [ 1130 | "proc-macro2", 1131 | "quote", 1132 | "syn", 1133 | ] 1134 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "rsop" 3 | version = "1.4.2" 4 | edition = "2018" 5 | 6 | [profile.release] 7 | lto = true 8 | codegen-units = 1 9 | strip = true 10 | 11 | [dependencies] 12 | anyhow = { version = "1.0.93", default-features = false, features = ["backtrace", "std"] } 13 | clap = { version = "4.5.20", default-features = false, features = ["std", "color", "help", "usage", "error-context", "suggestions", "derive"] } 14 | const_format = { version = "0.2.33", default-features = false, features = ["const_generics"] } 15 | crossbeam-utils = { version = "0.8.20", default-features = false, features = ["std"] } 16 | log = { version = "0.4.22", default-features = false, features = ["max_level_trace", "release_max_level_info"] } 17 | regex = { version = "1.11.1", default-features = false, features = ["std"] } 18 | serde = { version = "1.0.214", default-features = false, features = ["derive", "std"] } 19 | shlex = { version = "1.3.0", default-features = false, features = ["std"] } 20 | simple_logger = { version = "5.0.0", default-features = false, features = ["colors", "stderr"] } 21 | strum = { version = "0.26.3", default-features = false, features = ["derive", "std"] } 22 | tempfile = { version = "3.14.0", default-features = false } 23 | termion = { version = "4.0.3", default-features = false } 24 | thiserror = { version = "2.0.1", default-features = false } 25 | toml = { version = "0.8.19", default-features = false, features = ["parse"] } 26 | tree_magic_mini = { version = "3.1.6", default-features = false } 27 | url = { version = "2.5.3", default-features = false } 28 | xdg = { version = "2.5.2", default-features = false } 29 | 30 | [lints.rust] 31 | # https://doc.rust-lang.org/rustc/lints/listing/allowed-by-default.html 32 | explicit_outlives_requirements = "warn" 33 | missing_docs = "warn" 34 | non_ascii_idents = "deny" 35 | redundant-lifetimes = "warn" 36 | single-use-lifetimes = "warn" 37 | unit-bindings = "warn" 38 | unreachable_pub = "warn" 39 | unused_crate_dependencies = "warn" 40 | unused-lifetimes = "warn" 41 | unused-qualifications = "warn" 42 | 43 | [lints.clippy] 44 | pedantic = { level = "warn", priority = -1 } 45 | # below lints are from clippy::restriction, and assume clippy >= 1.82 46 | # https://rust-lang.github.io/rust-clippy/master/index.html#/?levels=allow&groups=restriction 47 | allow_attributes = "warn" 48 | clone_on_ref_ptr = "warn" 49 | dbg_macro = "warn" 50 | empty_enum_variants_with_brackets = "warn" 51 | expect_used = "warn" 52 | field_scoped_visibility_modifiers = "warn" 53 | fn_to_numeric_cast_any = "warn" 54 | format_push_string = "warn" 55 | if_then_some_else_none = "warn" 56 | impl_trait_in_params = "warn" 57 | infinite_loop = "warn" 58 | lossy_float_literal = "warn" 59 | mixed_read_write_in_expression = "warn" 60 | multiple_inherent_impl = "warn" 61 | needless_raw_strings = "warn" 62 | panic = "warn" 63 | pathbuf_init_then_push = "warn" 64 | pub_without_shorthand = "warn" 65 | redundant_type_annotations = "warn" 66 | ref_patterns = "warn" 67 | renamed_function_params = "warn" 68 | rest_pat_in_fully_bound_structs = "warn" 69 | same_name_method = "warn" 70 | semicolon_inside_block = "warn" 71 | shadow_unrelated = "warn" 72 | str_to_string = "warn" 73 | string_slice = "warn" 74 | string_to_string = "warn" 75 | tests_outside_test_module = "warn" 76 | try_err = "warn" 77 | undocumented_unsafe_blocks = "warn" 78 | unnecessary_safety_comment = "warn" 79 | unnecessary_safety_doc = "warn" 80 | unneeded_field_pattern = "warn" 81 | unseparated_literal_suffix = "warn" 82 | unused_result_ok = "warn" 83 | unwrap_used = "warn" 84 | verbose_file_reads = "warn" 85 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 desbma 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # rsop 2 | 3 | [![Build status](https://github.com/desbma/rsop/actions/workflows/ci.yml/badge.svg)](https://github.com/desbma/rsop/actions) 4 | [![AUR version](https://img.shields.io/aur/version/rsop-open.svg?style=flat)](https://aur.archlinux.org/packages/rsop-open/) 5 | [![License](https://img.shields.io/github/license/desbma/rsop.svg?style=flat)](https://github.com/desbma/rsop/blob/master/LICENSE) 6 | 7 | Simple, fast & configurable tool to open and preview files. 8 | 9 | Ideal for use with terminal file managers (ranger, lf, nnn, yazi, etc.). Can also be used as a general alternative to `xdg-open` and its various clones. 10 | 11 | If you spend most time in a terminal, and are unsatisfied by current solutions to associate file types with handler programs, this tool may be for you. 12 | 13 | ## Features 14 | 15 | - Start program to view/edit file according to extension or MIME type 16 | - Provides 4 commands (all symlinks to a single `rsop` binary): 17 | - `rso`: open file 18 | - `rse`: edit file (similar to `rso` in most cases, except when open to view or edit have a different handler) 19 | - `rsp`: preview files in terminal, to be used for example in terminal file managers or [`fzf`](https://github.com/junegunn/fzf) preview panel 20 | - `rsi`: to identify MIME type 21 | - Supports opening and previewing from data piped on stdin (very handy for advanced shell scripting, see [below](#show-me-some-cool-stuff-rsop-can-do)) 22 | - Supports chainable filters to preprocess data (for example to transparently handle `.log.xz` files) 23 | - Simple config file (no regex or funky conditionals) to describe file formats, handlers, and associate both 24 | - [`xdg-open`](https://linux.die.net/man/1/xdg-open) compatibility mode 25 | 26 | Compared to other `xdg-open` alternatives: 27 | 28 | - `rsop` is consistent and accurate, unlike say [ranger](https://github.com/ranger/ranger/issues/1804) 29 | - `rsop` does not rely on `.desktop` files (see section [Why no .desktop support](#why-no-desktop-support)) 30 | - `rsop` does opening and previewing with a single self contained tool and config file 31 | - `rsop` is not tied to a file manager or a runtime environment, you only need the `rsop` binary and your config file and can use it in interactive terminal sessions, file managers, `fzf` invocations... 32 | - `rsop` is taylored for terminal users (especially the preview feature) 33 | - `rsop` is very fast (see [performance](#performance) section) 34 | 35 | ## Installation 36 | 37 | ### From source 38 | 39 | You need a Rust build environment for example from [rustup](https://rustup.rs/). 40 | 41 | ``` 42 | cargo build --release 43 | install -Dm 755 -t /usr/local/bin target/release/rsop 44 | ln -rsv /usr/local/bin/rs{op,p} 45 | ln -rsv /usr/local/bin/rs{op,o} 46 | ln -rsv /usr/local/bin/rs{op,e} 47 | ln -rsv /usr/local/bin/rs{op,i} 48 | # to replace system xdg-open: 49 | ln -rsv /usr/local/bin/{rsop,xdg-open} 50 | ``` 51 | 52 | ### From the AUR 53 | 54 | Arch Linux users can install the [rsop-open AUR package](https://aur.archlinux.org/packages/rsop-open/). 55 | 56 | ## Configuration 57 | 58 | When first started, `rsop` will create a minimal configuration file usually in `~/.config/rsop/config.toml`. 59 | 60 | See comments and example in that file to set up file types and handlers for your needs. 61 | 62 | A more advanced example configuration file is also available [here](./config/config.toml.advanced). 63 | 64 | ### Usage with [ranger](https://github.com/ranger/ranger) 65 | 66 | **Warning: because ranger is built on Python's old ncurses version, the preview panel only supports 8bit colors (see https://github.com/ranger/ranger/issues/690#issuecomment-255590479), so if the output seems wrong you may need to tweak handlers to generate 8bit colors instead of 24.** 67 | 68 | In `rifle.conf`: 69 | 70 | = rso "$@" 71 | 72 | In `scope.sh`: 73 | 74 | #!/bin/sh 75 | COLUMNS="$2" LINES="$3" exec rsp "$1" 76 | 77 | Dont forget to make it executable with `chmod +x ~/.config/ranger/scope.sh`. 78 | 79 | ### Usage with [lf](https://github.com/gokcehan/lf) 80 | 81 | Add in `lfrc`: 82 | 83 | set filesep "\n" 84 | set ifs "\n" 85 | set previewer ~/.config/lf/preview 86 | cmd open ${{ 87 | for f in ${fx[@]}; do rso "${f}"; done 88 | lf -remote "send $id redraw" 89 | }} 90 | 91 | And create `~/.config/lf/preview` with: 92 | 93 | #!/bin/sh 94 | COLUMNS="$2" LINES="$3" exec rsp "$1" 95 | 96 | ## Usage with [yazi](https://github.com/sxyazi/yazi) 97 | 98 | Yazi has a complex LUA plugin system. Some built in previewers are superior to what `rsp` can provide (integrated image preview, seeking...), however in most cases `rsop` is more powerful and flexible, so this configuration mixes both built-in previewers and calls to `rsp`. Keep in mind the Yazi plugin API is not yet stable so this can break and requires changing frequently. 99 | 100 |
101 | ~/.config/yazi/yazi.toml 102 | 103 | ```yaml 104 | [plugin] 105 | previewers = [ 106 | { name = "*/", run = "folder" }, 107 | { mime = "image/{avif,hei?,jxl,svg+xml}", run = "magick" }, 108 | { mime = "image/*", run = "image" }, 109 | { mime = "font/*", run = "font" }, 110 | { mime = "application/vnd.ms-opentype", run = "font" }, 111 | { mime = "application/pdf", run = "pdf" }, 112 | { mime = "video/*", run = "video" }, 113 | { mime = "inode/empty", run = "empty" }, 114 | { name = "*", run = "rsp" }, 115 | ] 116 | 117 | [opener] 118 | open = [ 119 | { run = 'rso "$1"', desc = "Open", block = true }, 120 | ] 121 | edit = [ 122 | { run = 'rse "$1"', desc = "Edit" }, 123 | ] 124 | edit_block = [ 125 | { run = 'rse "$1"', desc = "Edit (blocking)", block = true }, 126 | ] 127 | 128 | [open] 129 | rules = [ 130 | { mime = "application/{,g}zip", use = [ "open", "extract" ] }, 131 | { mime = "application/{tar,bzip*,7z*,xz,rar}", use = [ "open", "extract" ] }, 132 | { mime = "text/*", use = [ "open", "edit_block" ] }, 133 | { name = "*", use = [ "open", "edit", "edit_block" ] }, 134 | ] 135 | ``` 136 | 137 |
138 | 139 |
140 | ~/.config/yazi/plugins/rsop/main.lua 141 | 142 | ```lua 143 | local M = {} 144 | 145 | function M:peek(job) 146 | local child = Command("rsp") 147 | :args({ 148 | tostring(job.file.url), 149 | }) 150 | :env("COLUMNS", tostring(job.area.w)) 151 | :env("LINES", tostring(job.area.h)) 152 | :stdout(Command.PIPED) 153 | :stderr(Command.PIPED) 154 | :spawn() 155 | 156 | if not child then 157 | return require("code").peek(job) 158 | end 159 | 160 | local limit = job.area.h 161 | local i, lines = 0, "" 162 | repeat 163 | local next, event = child:read_line() 164 | if event == 1 then 165 | return require("code").peek(job) 166 | elseif event ~= 0 then 167 | break 168 | end 169 | 170 | i = i + 1 171 | if i > job.skip then 172 | lines = lines .. next 173 | end 174 | until i >= job.skip + limit 175 | 176 | child:start_kill() 177 | if job.skip > 0 and i < job.skip + limit then 178 | ya.emit("peek", { math.max(0, i - limit), only_if = job.file.url, upper_bound = true }) 179 | else 180 | lines = lines:gsub("\t", string.rep(" ", rt.preview.tab_size)) 181 | ya.preview_widgets(job, { 182 | ui.Text.parse(lines):area(job.area):wrap(rt.preview.wrap == "yes" and ui.Text.WRAP or ui.Text.WRAP_NO), 183 | }) 184 | end 185 | end 186 | 187 | function M:seek(job) require("code"):seek(job) end 188 | 189 | return M 190 | ``` 191 | 192 |
193 | 194 | ## Show me some cool stuff `rsop` can do 195 | 196 | - Simple file explorer with fuzzy searching, using [fd](https://github.com/sharkdp/fd) and [fzf](https://github.com/junegunn/fzf), using `rso` to preview files and `rsp` to open them: 197 | 198 | ``` 199 | fd . | fzf --preview='rsp {}' | xargs -r rso 200 | ``` 201 | 202 | [![file explorer](./demo/file-explorer.gif)](https://raw.githubusercontent.com/desbma/rsop/master/demo/file-explorer.gif) 203 | 204 | - Preview files inside an archive, **without decompressing it entirely**, select one and open it (uses [`bsdtar`](https://www.libarchive.org/), [`fzf`](https://github.com/junegunn/fzf) and `rso`/`rsp`): 205 | 206 | ``` 207 | # preview archive (.tar, .tar.gz, .zip, .7z, etc.) 208 | # usage: pa 209 | pa() { 210 | local -r archive="${1:?}" 211 | bsdtar -tf "${archive}" | 212 | grep -v '/$' | 213 | fzf --preview="bsdtar -xOf \"${archive}\" {} | rsp" | 214 | xargs -r bsdtar -xOf "${archive}" | 215 | rso 216 | } 217 | ``` 218 | 219 | [![preview archive](./demo/preview-archive.gif)](https://raw.githubusercontent.com/desbma/rsop/master/demo/preview-archive.gif) 220 | 221 | **This is now integrated in the [advanced config example](./config/config.toml.advanced), so you can just run `rso ` and get the same result.** 222 | 223 | ## Performance 224 | 225 | `rsop` is quite fast. In practice it rarely matters because choosing with which program to open or preview files is usually so quick it is not perceptible. However performance can matter if for example you are decompressing a huge `tar.gz` archive to preview its content. 226 | To help with that, `rsop` uses the [`splice` system call](https://man7.org/linux/man-pages/man2/splice.2.html) if available on your platform. In the `.tar.gz` example this allows decompressing data with `gzip` or `pigz` and passing it to tar (or whatever you have configured to handle `application/x-tar` MIME type), **without wasting time to copy data in user space** between the two programs. This was previously done using a custom code path, but is now done [transparently](https://github.com/rust-lang/rust/pull/75272) by the standard library. 227 | 228 | Other stuff `rsop` does to remain quick: 229 | 230 | - it is written in Rust (setting aside the RiiR memes, this avoid the 20-50ms startup time of for example Python interpreters) 231 | - it uses hashtables to search for handlers from MIME types or extensions in constant time 232 | - it uses the great [tree_magic_mini crate](https://crates.io/crates/tree_magic_mini) for fast MIME identification 233 | 234 | ## FAQ 235 | 236 | ### Why no [`.desktop`](https://specifications.freedesktop.org/desktop-entry-spec/latest/) support? 237 | 238 | - `.desktop` do not provide a _preview_ action separate from _open_. 239 | - One may need to pipe several programs to get to desired behavior, `.desktop` files does not help with this. 240 | - Many programs do not ship one, especially command line tools, so this would be incomplete anyway. 241 | - On a philosophical level, with `.desktop` files, the program's author (or packager) decides which MIME types to support, and which arguments to pass to the program. This is a wrong paradidm, as this is fundamentally a user's decision. 242 | 243 | ### What does `rsop` stands for? 244 | 245 | "**R**eally **S**imple **O**pener/**P**reviewer" or "**R**eliable **S**imple **O**pener/**P**reviewer" or "**R**u**S**t **O**pener/**P**reviewer" 246 | 247 | I haven't really decided yet... 248 | 249 | ### What is the difference between the open, edit and preview actions? 250 | 251 | Each action has customizable handlers, so they only do what you set them to do. 252 | 253 | However the philosophy is the following : 254 | 255 | - preview is **non interactive**, typically with only terminal UI, and a maximum number of lines in the output 256 | - open can be interactive or not, and can open graphical windows or not 257 | - edit is **interactive**, defaults to the open handler if no edit handler is set, and only makes sense if you need an action separate from open, for example to edit images with GIMP versus to view them with just an image viewer 258 | 259 | ## License 260 | 261 | [MIT](./LICENSE) 262 | -------------------------------------------------------------------------------- /cliff.toml: -------------------------------------------------------------------------------- 1 | # configuration file for git-cliff (0.1.0) 2 | 3 | [changelog] 4 | # changelog header 5 | header = """ 6 | # Changelog 7 | 8 | All notable changes to this project will be documented in this file. 9 | 10 | """ 11 | # template for the changelog body 12 | # https://tera.netlify.app/docs/#introduction 13 | body = """ 14 | {% if version %}\ 15 | ## {{ version | replace(from="v", to="") }} - {{ timestamp | date(format="%Y-%m-%d") }} 16 | {% else %}\ 17 | ## _unreleased_ 18 | {% endif %}\ 19 | {% for group, commits in commits | group_by(attribute="group") %} 20 | ### {{ group | upper_first }} 21 | {% for commit in commits %} 22 | - {{ commit.message | upper_first }}\ 23 | {% endfor %} 24 | {% endfor %}\n 25 | """ 26 | # remove the leading and trailing whitespaces from the template 27 | trim = true 28 | # changelog footer 29 | footer = """ 30 | 31 | """ 32 | 33 | [git] 34 | # allow only conventional commits 35 | # https://www.conventionalcommits.org 36 | conventional_commits = true 37 | # regex for parsing and grouping commits 38 | commit_parsers = [ 39 | { message = "^feat*", group = "Features"}, 40 | { message = "^fix*", group = "Bug Fixes"}, 41 | { message = "^doc*", group = "Documentation"}, 42 | { message = "^perf*", group = "Performance"}, 43 | { message = "^refactor*", group = "Refactor"}, 44 | { message = "^style*", group = "Styling"}, 45 | { message = "^test*", group = "Testing"}, 46 | { message = "^chore\\(release\\): prepare for*", skip = true}, 47 | { message = "^chore*", group = "Miscellaneous Tasks"}, 48 | { body = ".*security", group = "Security"}, 49 | { message = "^config*", group = "Configuration"}, 50 | ] 51 | # filter out the commits that are not matched by commit parsers 52 | filter_commits = false 53 | # glob pattern for matching git tags 54 | tag_pattern = "[0-9.]*" 55 | # regex for skipping tags 56 | #skip_tags = "" 57 | -------------------------------------------------------------------------------- /clippy.toml: -------------------------------------------------------------------------------- 1 | allow-expect-in-tests = true 2 | allow-panic-in-tests = true 3 | allow-unwrap-in-tests = true 4 | avoid-breaking-exported-api = false 5 | -------------------------------------------------------------------------------- /config/config.toml.advanced: -------------------------------------------------------------------------------- 1 | # 2 | # This is an example of config file for rsop, showing advanced usage. 3 | # 4 | # It makes use of many external programs you may need to install from your distribution packages or from source: 5 | # - atril https://github.com/mate-desktop/atril 6 | # - bat https://github.com/sharkdp/bat 7 | # - bsdtar https://www.libarchive.org/ 8 | # - chafa https://hpjansson.org/chafa/ 9 | # - delta https://github.com/dandavison/delta 10 | # - dpkg https://wiki.debian.org/Teams/Dpkg 11 | # - ffmpegthumbnailer https://github.com/dirkvdb/ffmpegthumbnailer 12 | # - ffprobe https://ffmpeg.org/ 13 | # - firefox https://firefox.com/ 14 | # - hexyl https://github.com/sharkdp/hexyl 15 | # - imv https://github.com/eXeC64/imv 16 | # - libreoffice https://www.libreoffice.org/ 17 | # - lsd https://github.com/Peltoche/lsd 18 | # - mdcat https://github.com/lunaryorn/mdcat 19 | # - mediainfo https://mediaarea.net/en/MediaInfo 20 | # - moreutils https://joeyh.name/code/moreutils/ 21 | # - mpv https://mpv.io/ 22 | # - odt2txt https://github.com/dstosberg/odt2txt/ 23 | # - openscad https://openscad.org/ 24 | # - openssl https://www.openssl.org/ 25 | # - pandoc https://pandoc.org/ 26 | # - pbzip2 http://compression.ca/pbzip2/ 27 | # - pigz https://www.zlib.net/pigz/ 28 | # - pdftoppm https://poppler.freedesktop.org/ 29 | # - retext https://github.com/retext-project/retext 30 | # - sqlite3 https://www.sqlite.org/index.html 31 | # - ss https://git.kernel.org/pub/scm/network/iproute2/iproute2.git 32 | # - transmission-remote-gtk https://github.com/transmission-remote-gtk/transmission-remote-gtk 33 | # - transmission-show https://transmissionbt.com/ 34 | # - tree http://mama.indstate.edu/users/ice/tree/ 35 | # - tshark https://www.wireshark.org/ 36 | # - tuxguitar http://www.tuxguitar.com.ar/ 37 | # - w3m https://salsa.debian.org/debian/w3m 38 | # - wireshark https://www.wireshark.org/ 39 | # - xz https://tukaani.org/xz/ 40 | # - zstd https://facebook.github.io/zstd/ 41 | # 42 | # You most likely want to edit this file to fit your needs. 43 | # 44 | 45 | # 46 | # File types, identified by extension or MIME type 47 | # 48 | # - extensions 49 | # List of extensions, always checked before MIME type. Double extensions (ie. 'tar.gz') are supported, although it usually 50 | # makes more sense to use a filter instead. 51 | # 52 | # - mimes 53 | # List of MIME types, a prefix (part before the '+', '.' or '/') can be used to match several subtypes. 54 | # Compared to identification by extension this has the advantage of also working with data piped from stdin. 55 | # 56 | 57 | [filetype.archive] 58 | mimes = [ 59 | "application/java-archive", 60 | "application/vnd.rar", 61 | "application/x-7z-compressed", 62 | "application/x-archive", 63 | "application/x-cpio", 64 | "application/x-rar", 65 | "application/x-rpm", 66 | "application/x-tar", 67 | "application/zip" 68 | ] 69 | # bsdtar can decompress transparently 70 | extensions = ["iso", "tar.bz2", "tar.gz", "tar.xz", "tar.zst"] 71 | 72 | [filetype.audio] 73 | mimes = ["audio", "video/ogg"] 74 | extensions = ["m4a", "ogg"] 75 | 76 | [filetype.binary] 77 | mimes = ["application/octet-stream"] 78 | 79 | [filetype.bzip2] 80 | mimes = ["application/x-bzip2"] 81 | 82 | [filetype.certificate] 83 | mimes = ["application/pkix-cert"] 84 | 85 | [filetype.deb] 86 | extensions = ["deb"] 87 | mimes = ["application/vnd.debian.binary-package"] 88 | 89 | [filetype.directory] 90 | mimes = ["inode/directory"] 91 | 92 | [filetype.dot] 93 | extensions = ["dot"] 94 | mimes = ["text/vnd.graphviz"] 95 | 96 | [filetype.drawio] 97 | extensions = ["drawio"] 98 | 99 | [filetype.epub] 100 | mimes = ["application/epub"] 101 | 102 | [filetype.gif] 103 | extensions = ["gif"] 104 | mimes = ["image/gif"] 105 | 106 | [filetype.graph] 107 | extensions = ["graph"] 108 | 109 | [filetype.guitar_tab] 110 | extensions = ["gp3", "gp4", "gp5", "ptb"] 111 | 112 | [filetype.gzip] 113 | mimes = ["application/gzip"] 114 | 115 | [filetype.html] 116 | extensions = ["htm", "html", "xhtml"] 117 | mimes = ["text/html"] 118 | 119 | [filetype.image] 120 | mimes = ["image"] 121 | 122 | [filetype.jpeg] 123 | mimes = ["image/jpeg"] 124 | 125 | [filetype.markdown] 126 | extensions = ["md"] 127 | 128 | [filetype.mobi] 129 | extensions = ["mobi"] 130 | 131 | [filetype.motion_jpeg] 132 | extensions = ["mp.jpg"] 133 | 134 | [filetype.msdocument] 135 | extensions = ["doc", "docx", "pptx", "rtf", "xlsx"] 136 | mimes = ["application/vnd.openxmlformats-officedocument", "text/rtf"] 137 | 138 | [filetype.opendocument] 139 | extensions = ["odg", "odp", "ods", "odt"] 140 | mimes = ["application/vnd.oasis.opendocument"] 141 | 142 | [filetype.patch] 143 | mimes = ["text/x-patch"] 144 | extensions = ["patch"] 145 | 146 | [filetype.pcap] 147 | mimes = ["application/vnd.tcpdump.pcap", "application/x-pcapng"] 148 | 149 | [filetype.pdf] 150 | mimes = ["application/pdf"] 151 | 152 | [filetype.scad] 153 | extensions = ["scad"] 154 | 155 | [filetype.socket] 156 | mimes = ["inode/socket"] 157 | 158 | [filetype.sqlite] 159 | mimes = ["application/vnd.sqlite3"] 160 | 161 | [filetype.svg] 162 | mimes = ["image/svg"] 163 | extensions = ["svg"] 164 | 165 | [filetype.text] 166 | mimes = [ 167 | "text", 168 | "application/mbox", 169 | "application/pkcs8+pem", 170 | "application/x-desktop", 171 | "application/x-perl", 172 | "application/x-php", 173 | "application/x-shellscript", 174 | "application/x-subrip", 175 | "application/xml" 176 | ] 177 | 178 | [filetype.torrent] 179 | mimes = ["application/x-bittorrent"] 180 | 181 | [filetype.video] 182 | mimes = ["video", "application/vnd.ms-asf", "application/x-matroska", "application/x-riff"] 183 | extensions = ["3gp", "avi", "mp4", "ogv"] 184 | 185 | [filetype.xsv] 186 | extensions = ["csv", "tsv"] 187 | 188 | [filetype.xz] 189 | mimes = ["application/x-xz"] 190 | 191 | [filetype.zstandard] 192 | mimes = ["application/zstd"] 193 | 194 | 195 | # 196 | # File handlers 197 | # 198 | # - command 199 | # The command to run to open or preview file. 200 | # Substitution is done for the following expressions: 201 | # %c: terminal column count 202 | # %i: input path 203 | # %l: terminal line count 204 | # %m: input MIME type 205 | # Use '%%' if you need to pass a literal '%' char. 206 | # 207 | # - shell 208 | # If true, runs the command in a shell, use this if you use pipes. Defaults to false. 209 | # 210 | # - wait 211 | # If true, waits for the handler to exit. Defaults to true. 212 | # 213 | # - no_pipe 214 | # If true, disable piping data to handler's stdin, and use a slower temporary file instead if data is piped to rsop. 215 | # Incompatible with 'wait = false'. Defaults to false. 216 | # 217 | # - stdin_arg 218 | # When previewing or opening data from stdin, with what string to substitute '%i'. Defaults to "-", some programs require "". 219 | # 220 | 221 | [default_handler_preview] 222 | command = "echo '🔍 MIME: %m'; hexyl --border none %i | head -n $((%l - 1))" 223 | shell = true 224 | stdin_arg = "" 225 | 226 | [default_handler_open] 227 | command = "hexyl %i | less -R" 228 | shell = true 229 | stdin_arg = "" 230 | 231 | [handler_preview.archive] 232 | command = "echo '🔍 MIME: %m'; bsdtar -tf %i | grep -v '/$' | tree -C --noreport --fromfile . | tail -n +2 | sed 's@^....@@' | head -n $((%l - 3))" 233 | shell = true 234 | 235 | [handler_open.archive] 236 | command = "bsdtar -tf %i | grep -v /$ | fzf -m --preview=\"bsdtar -xOf %i {} | rsp\" --print0 | xargs -0r bsdtar -xOf %i | ifne rso" 237 | shell = true 238 | no_pipe = true 239 | 240 | [handler_preview.audio] 241 | command = "mediainfo %i | sed 's@ \\+: @: @' | column -s ':' -t -l 2 | sed 's@ *$@@'" 242 | shell = true 243 | 244 | [handler_open.audio] 245 | command = "mpv %i" 246 | wait = false 247 | 248 | [handler_preview.binary] 249 | command = "hexyl --border none %i | head -n %l" 250 | shell = true 251 | stdin_arg = "" 252 | 253 | [handler_preview.certificate] 254 | command = "openssl x509 -in %i -text" 255 | 256 | [handler_open.certificate] 257 | command = "openssl x509 -in %i -text | less -R" 258 | shell = true 259 | 260 | [handler_preview.deb] 261 | command = "dpkg -c %i | head -n %l" 262 | shell = true 263 | 264 | [handler_preview.directory] 265 | command = "lsd -alFh --tree --color=always --icon=always %i | head -n %l" 266 | shell = true 267 | 268 | [handler_open.directory] 269 | command = "lsd -alFh --tree --color=always --icon=always %i | less -R" 270 | shell = true 271 | 272 | [handler_preview.dot] 273 | command = "dot -Tdot %i | graph-easy --as=boxart 2> /dev/null" 274 | shell = true 275 | stdin_arg = "" 276 | 277 | [handler_open.dot] 278 | command = "dot -Tsvg %i | rso" 279 | shell = true 280 | stdin_arg = "" 281 | 282 | [handler_edit.drawio] 283 | command = "drawio %i" 284 | no_pipe = true 285 | 286 | [handler_open.epub] 287 | command = "atril %i" 288 | no_pipe = true 289 | 290 | [handler_open.gif] 291 | command = "mpv --loop %i" 292 | wait = false 293 | 294 | [handler_preview.graph] 295 | command = "graph-easy --as=boxart %i" 296 | stdin_arg = "" 297 | 298 | [handler_open.graph] 299 | command = "graph-easy --as=dot %i | dot -Tsvg | rso" 300 | stdin_arg = "" 301 | shell = true 302 | 303 | [handler_open.guitar_tab] 304 | command = "tuxguitar %i" 305 | no_pipe = true 306 | 307 | [handler_preview.html] 308 | command = "w3m -dump %i" 309 | no_pipe = true 310 | 311 | [handler_open.html] 312 | command = "firefox %i" 313 | no_pipe = true 314 | 315 | [handler_preview.image] 316 | command = "chafa -s %cx%l %i" 317 | 318 | [handler_open.image] 319 | command = "imv %i" 320 | wait = false 321 | 322 | [handler_edit.image] 323 | command = "gimp %i" 324 | no_pipe = true 325 | 326 | [handler_preview.jpeg] 327 | command = "exiftran -a -o /dev/stdout %i | chafa -s %cx%l" 328 | shell = true 329 | stdin_arg = "/dev/stdin" 330 | 331 | [handler_preview.markdown] 332 | command = "mdcat %i" 333 | 334 | [handler_edit.markdown] 335 | command = "retext %i" 336 | wait = false 337 | 338 | [handler_open.mobi] 339 | command = "FBReader %i" 340 | no_pipe = true 341 | 342 | [handler_preview.msdocument] 343 | command = "pandoc -s -t markdown -- %i | mdcat" 344 | shell = true 345 | 346 | [handler_edit.msdocument] 347 | command = "libreoffice %i" 348 | no_pipe = true 349 | 350 | [handler_preview.opendocument] 351 | command = "odt2txt %i" 352 | no_pipe = true 353 | 354 | [handler_edit.opendocument] 355 | command = "libreoffice %i" 356 | no_pipe = true 357 | 358 | [handler_preview.patch] 359 | command = "cat %i | delta" 360 | shell = true 361 | 362 | [handler_preview.pcap] 363 | command = "tshark -t a -r %i | head -n %l" 364 | shell = true 365 | 366 | [handler_open.pcap] 367 | command = "wireshark %i" 368 | no_pipe = true 369 | 370 | [handler_preview.pdf] 371 | command = "t=$(mktemp); pdftoppm -f 1 -l 1 -scale-to-x 800 -scale-to-y -1 -singlefile -jpeg -jpegopt quality=60 -tiffcompression jpeg -- %i \"${t}\" && chafa -s %cx%l \"${t}.jpg\"; rm \"${t}.jpg\"" 372 | shell = true 373 | 374 | [handler_open.pdf] 375 | command = "atril %i" 376 | no_pipe = true 377 | 378 | [handler_preview.scad] 379 | command = "openscad -q --render --colorscheme=Solarized --imgsize=800,600 --export-format png -o - %i | chafa -s %cx%l -" 380 | shell = true 381 | no_pipe = true 382 | 383 | [handler_edit.scad] 384 | command = "openscad %i" 385 | no_pipe = true 386 | 387 | [handler_preview.socket] 388 | command = "ss -alxp src unix:'%i'" 389 | 390 | [handler_preview.sqlite] 391 | command = "sqlite3 %i .dump | bat -P --color=always -n --terminal-width %c -r :%l -l sql" 392 | shell = true 393 | no_pipe = true 394 | 395 | [handler_open.sqlite] 396 | command = "sqlite3 %i .dump | bat --paging always --color=always -n --terminal-width %c -l sql" 397 | shell = true 398 | no_pipe = true 399 | 400 | [handler_preview.svg] 401 | command = "chafa -s %cx%l %i" 402 | 403 | [handler_open.svg] 404 | command = "firefox %i" 405 | no_pipe = true 406 | 407 | [handler_preview.text] 408 | command = "bat -P --color=always -n --terminal-width %c -r :%l %i" 409 | 410 | [handler_open.text] 411 | command = "bat --paging always --color=always -n --terminal-width %c %i" 412 | 413 | [handler_preview.torrent] 414 | command = "transmission-show -- %i" 415 | no_pipe = true 416 | 417 | [handler_open.torrent] 418 | command = "transmission-remote-gtk -- %i" 419 | no_pipe = true 420 | 421 | [handler_preview.video] 422 | command = "s=$(ffprobe -hide_banner %i 2>&1 | grep -E '^ (Stream|Duration)' | sed 's@^ Stream [^ ]*:@@' | sed 's@ *@@'); l=$(echo \"$s\" | wc -l); ffmpegthumbnailer -s 800 -c jpg -q 6 -i %i -o - 2> /dev/null | chafa -s %cx$((%l - l)) -; echo \"$s\"" 423 | shell = true 424 | no_pipe = true 425 | 426 | [handler_open.video] 427 | command = "mpv --loop %i" 428 | no_pipe = true 429 | 430 | [handler_preview.xsv] 431 | command = "bat -P --color=always -n --terminal-width %c -r :%l -l csv %i" 432 | 433 | [handler_open.xsv] 434 | command = "libreoffice %i" 435 | no_pipe = true 436 | 437 | 438 | # 439 | # Filters 440 | # 441 | # Filters are special handlers that process their input and send their output either to another filter or to a final handler. 442 | # They are typically useful to transparently decompress files like .log.xz, .pcapng.gz, tar.gz, etc. 443 | # but you can also use it for more specific needs like converting some document formats to markdown and then using your usual handler 444 | # for markdown files to preview or open it. 445 | # Filter configuration parameters are similar to handler, except wait that is implied as true. 446 | # 447 | 448 | [filter.bzip2] 449 | command = "pbzip2 -dc %i" 450 | 451 | [filter.gzip] 452 | command = "pigz -dc %i" 453 | 454 | [filter.motion_jpeg] 455 | # https://linuxreviews.org/Google_Pixel_%22Motion_Photo%22 456 | command = "tail -c +$(( $(grep -EUboa '(ftypisom|ftypmp42)' %i | cut -d ':' -f 1) - 3)) %i 2> /dev/null || cat %i" 457 | shell = true 458 | no_pipe = true 459 | 460 | [filter.xz] 461 | command = "xz -dc %i" 462 | 463 | [filter.zstandard] 464 | command = "zstd -dc %i" 465 | 466 | 467 | # 468 | # Scheme handlers 469 | # 470 | # Handlers for use in 'xdg-open' mode, with URLs instead of paths. URLs prefixed with 'file://' are handled by file handlers. 471 | # Configuration is similar to file handlers, but only 'command' and 'shell' parameters are supported. 472 | # 473 | 474 | [handler_scheme.http] 475 | command = "firefox %i" 476 | 477 | [handler_scheme.https] 478 | command = "firefox %i" 479 | -------------------------------------------------------------------------------- /config/config.toml.default: -------------------------------------------------------------------------------- 1 | # 2 | # This is a default minimal config file for rsop. 3 | # For a more advanced config example, see https://raw.githubusercontent.com/desbma/rsop/master/config/config.toml.advanced 4 | # You most likely want to edit this file to fit your needs. 5 | # 6 | 7 | # 8 | # File types, identified by extension or MIME type 9 | # 10 | # - extensions 11 | # List of extensions, always checked before MIME type. Double extensions (ie. 'tar.gz') are supported, although it usually 12 | # makes more sense to use a filter instead. 13 | # 14 | # - mimes 15 | # List of MIME types, a prefix (part before the '+', '.' or '/') can be used to match several subtypes. 16 | # Compared to identification by extension this has the advantage of also working with data piped from stdin. 17 | # 18 | 19 | [filetype.gzip] 20 | mimes = ["application/gzip"] 21 | 22 | [filetype.text] 23 | mimes = ["text"] 24 | 25 | 26 | # 27 | # File handlers 28 | # 29 | # - command 30 | # The command to run to open or preview file. 31 | # Substitution is done for the following expressions: 32 | # %c: terminal column count 33 | # %i: input path 34 | # %l: terminal line count 35 | # %m: input MIME type 36 | # Use '%%' if you need to pass a literal '%' char. 37 | # 38 | # - shell 39 | # If true, runs the command in a shell, use this if you use pipes. Defaults to false. 40 | # 41 | # - wait 42 | # If true, waits for the handler to exit. Defaults to true. 43 | # 44 | # - no_pipe 45 | # If true, disable piping data to handler's stdin, and use a slower temporary file instead if data is piped to rsop. 46 | # Incompatible with 'wait = false'. Defaults to false. 47 | # 48 | # - stdin_arg 49 | # When previewing or opening data from stdin, with what string to substitute '%i'. Defaults to "-", some programs require "". 50 | # 51 | 52 | [default_handler_preview] 53 | command = "file %i" 54 | 55 | [default_handler_open] 56 | command = "cat -A %i" 57 | 58 | [handler_preview.text] 59 | command = "head -n %l %i" 60 | 61 | [handler_open.text] 62 | command = "less %i" 63 | 64 | 65 | # 66 | # Filters 67 | # 68 | # Filters are special handlers that process their input and send their output either to another filter or to a final handler. 69 | # They are typically useful to transparently decompress files like .log.xz, .pcapng.gz, tar.gz, etc. 70 | # but you can also use it for more specific needs like converting some document formats to markdown and then using your usual handler 71 | # for markdown files to preview or open it. 72 | # Filter configuration parameters are similar to handler, except wait that is implied as true. 73 | # 74 | 75 | [filter.gzip] 76 | command = "gzip -dc %i" 77 | 78 | 79 | # 80 | # Scheme handlers 81 | # 82 | # Handlers for use in 'xdg-open' mode, with URLs instead of paths. URLs prefixed with 'file://' are handled by file handlers. 83 | # Configuration is similar to file handlers, but only 'command' and 'shell' parameters are supported. 84 | # 85 | 86 | [handler_scheme.http] 87 | command = "firefox %i" 88 | 89 | [handler_scheme.https] 90 | command = "firefox %i" 91 | -------------------------------------------------------------------------------- /config/config.toml.tiny: -------------------------------------------------------------------------------- 1 | # 2 | # This is a the smallest config accepted by rsop. 3 | # Only useful for automated tests. 4 | # 5 | 6 | [default_handler_preview] 7 | command = "file %i" 8 | 9 | [default_handler_open] 10 | command = "cat -A %i" 11 | -------------------------------------------------------------------------------- /demo/file-explorer.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/desbma/rsop/36f38a8cc6035992f38dfa02f3da36b75a66deac/demo/file-explorer.gif -------------------------------------------------------------------------------- /demo/populate: -------------------------------------------------------------------------------- 1 | #!/bin/bash -eu 2 | 3 | set -o pipefail 4 | 5 | ensure_sample() { 6 | local -r url="${1:?}" 7 | local -r ext="${2:-${url##*.}}" 8 | local -t output="sample.${ext}" 9 | 10 | if [ ! -f "${output}" ] 11 | then 12 | curl "${url}" -o "${output}" 13 | fi 14 | } 15 | 16 | cd "$(dirname -- "$0")" 17 | 18 | if [ "${1:-}" = 'clean' ] 19 | then 20 | rm -f sample.* 21 | 22 | else 23 | # https://commons.wikimedia.org/wiki/Category:Commons_sample_files 24 | ensure_sample 'https://upload.wikimedia.org/wikipedia/commons/4/4d/GridRPC_paradigm.pdf' 25 | ensure_sample 'https://upload.wikimedia.org/wikipedia/commons/5/5a/Test-kdenlive-title.webm' 26 | ensure_sample 'https://upload.wikimedia.org/wikipedia/commons/8/8d/Qsicon_inArbeit_%28jha%29.svg' 27 | cp ../src/handler.rs sample.rs 28 | cp ../README.md sample.md 29 | fi 30 | -------------------------------------------------------------------------------- /demo/preview-archive.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/desbma/rsop/36f38a8cc6035992f38dfa02f3da36b75a66deac/demo/preview-archive.gif -------------------------------------------------------------------------------- /demo/record: -------------------------------------------------------------------------------- 1 | #!/bin/bash -eu 2 | 3 | readonly OUTPUT="${1:?}" 4 | 5 | reset 6 | 7 | t-rec -q -d none -n -s 300ms -e 800ms "$SHELL" 8 | 9 | mv t-rec.gif "${OUTPUT}" 10 | -------------------------------------------------------------------------------- /release: -------------------------------------------------------------------------------- 1 | #!/bin/bash -eu 2 | 3 | set -o pipefail 4 | 5 | readonly VERSION="${1:?}" 6 | 7 | cd "$(git rev-parse --show-toplevel)" 8 | 9 | cargo set-version "${VERSION}" 10 | 11 | cargo upgrade 12 | cargo update 13 | 14 | cargo check 15 | cargo test 16 | 17 | git add Cargo.{toml,lock} 18 | 19 | git commit -m "chore: version ${VERSION}" 20 | git tag -m "Version ${VERSION}" "${VERSION}" 21 | 22 | git cliff 1.0.0..HEAD > CHANGELOG.md 23 | git add CHANGELOG.md 24 | git commit --amend --no-edit 25 | 26 | git tag -f -m "Version ${VERSION}" "${VERSION}" 27 | -------------------------------------------------------------------------------- /src/cli.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use clap::Parser; 4 | 5 | #[derive(Debug, Parser)] 6 | #[structopt(version=env!("CARGO_PKG_VERSION"), about="Open or preview files.")] 7 | pub(crate) struct CommandLineOpts { 8 | pub path: Option, 9 | } 10 | -------------------------------------------------------------------------------- /src/config.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | collections::HashMap, 3 | fs::File, 4 | io::Write, 5 | path::{Path, PathBuf}, 6 | }; 7 | 8 | #[derive(Debug, serde::Deserialize)] 9 | pub(crate) struct Filetype { 10 | #[serde(default)] 11 | pub extensions: Vec, 12 | 13 | #[serde(default)] 14 | pub mimes: Vec, 15 | } 16 | 17 | #[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize)] 18 | pub(crate) struct FileHandler { 19 | pub command: String, 20 | #[serde(default = "default_file_handler_wait")] 21 | pub wait: bool, 22 | #[serde(default)] 23 | pub shell: bool, 24 | #[serde(default)] 25 | pub no_pipe: bool, 26 | pub stdin_arg: Option, 27 | } 28 | 29 | const fn default_file_handler_wait() -> bool { 30 | true 31 | } 32 | 33 | #[derive(Clone, Debug, serde::Deserialize)] 34 | pub(crate) struct FileFilter { 35 | pub command: String, 36 | #[serde(default)] 37 | pub shell: bool, 38 | #[serde(default)] 39 | pub no_pipe: bool, 40 | pub stdin_arg: Option, 41 | } 42 | 43 | #[derive(Clone, Debug, serde::Deserialize)] 44 | pub(crate) struct SchemeHandler { 45 | pub command: String, 46 | #[serde(default)] 47 | pub shell: bool, 48 | } 49 | 50 | #[derive(Debug, serde::Deserialize)] 51 | pub(crate) struct Config { 52 | #[serde(default)] 53 | pub filetype: HashMap, 54 | 55 | #[serde(default)] 56 | pub handler_preview: HashMap, 57 | pub default_handler_preview: FileHandler, 58 | 59 | #[serde(default)] 60 | pub handler_open: HashMap, 61 | pub default_handler_open: FileHandler, 62 | 63 | #[serde(default)] 64 | pub handler_edit: HashMap, 65 | 66 | #[serde(default)] 67 | pub filter: HashMap, 68 | 69 | #[serde(default)] 70 | pub handler_scheme: HashMap, 71 | } 72 | 73 | pub(crate) fn parse_config() -> anyhow::Result { 74 | parse_config_path(&get_config_path()?) 75 | } 76 | 77 | fn get_config_path() -> anyhow::Result { 78 | const CONFIG_FILENAME: &str = "config.toml"; 79 | const DEFAULT_CONFIG_STR: &str = include_str!("../config/config.toml.default"); 80 | let binary_name = env!("CARGO_PKG_NAME"); 81 | let xdg_dirs = xdg::BaseDirectories::with_prefix(binary_name)?; 82 | let config_filepath = if let Some(p) = xdg_dirs.find_config_file(CONFIG_FILENAME) { 83 | p 84 | } else { 85 | let path = xdg_dirs.place_config_file(CONFIG_FILENAME)?; 86 | log::warn!("No config file found, creating a default one in {:?}", path); 87 | let mut file = File::create(&path)?; 88 | file.write_all(DEFAULT_CONFIG_STR.as_bytes())?; 89 | path 90 | }; 91 | 92 | log::debug!("Config filepath: {:?}", config_filepath); 93 | 94 | Ok(config_filepath) 95 | } 96 | 97 | fn parse_config_path(path: &Path) -> anyhow::Result { 98 | let toml_data = std::fs::read_to_string(path)?; 99 | log::trace!("Config data: {:?}", toml_data); 100 | 101 | let mut config: Config = toml::from_str(&toml_data)?; 102 | // Normalize extensions to lower case 103 | for filetype in config.filetype.values_mut() { 104 | filetype.extensions = filetype 105 | .extensions 106 | .iter() 107 | .map(|e| e.to_lowercase()) 108 | .collect(); 109 | } 110 | log::trace!("Config: {:?}", config); 111 | 112 | Ok(config) 113 | } 114 | 115 | #[cfg(test)] 116 | mod tests { 117 | use super::*; 118 | 119 | #[test] 120 | fn test_tiny_config() { 121 | const TINY_CONFIG_STR: &str = include_str!("../config/config.toml.tiny"); 122 | let mut config_file = tempfile::NamedTempFile::new().unwrap(); 123 | config_file.write_all(TINY_CONFIG_STR.as_bytes()).unwrap(); 124 | 125 | let res = parse_config_path(config_file.path()); 126 | assert!(res.is_ok()); 127 | let config = res.unwrap(); 128 | 129 | assert_eq!(config.filetype.len(), 0); 130 | assert_eq!(config.handler_preview.len(), 0); 131 | assert_eq!( 132 | config.default_handler_preview, 133 | FileHandler { 134 | command: "file %i".to_owned(), 135 | wait: true, 136 | shell: false, 137 | no_pipe: false, 138 | stdin_arg: None 139 | } 140 | ); 141 | assert_eq!(config.handler_open.len(), 0); 142 | assert_eq!( 143 | config.default_handler_open, 144 | FileHandler { 145 | command: "cat -A %i".to_owned(), 146 | wait: true, 147 | shell: false, 148 | no_pipe: false, 149 | stdin_arg: None 150 | } 151 | ); 152 | assert_eq!(config.filter.len(), 0); 153 | } 154 | 155 | #[test] 156 | fn test_default_config() { 157 | const DEFAULT_CONFIG_STR: &str = include_str!("../config/config.toml.default"); 158 | let mut config_file = tempfile::NamedTempFile::new().unwrap(); 159 | config_file 160 | .write_all(DEFAULT_CONFIG_STR.as_bytes()) 161 | .unwrap(); 162 | 163 | let res = parse_config_path(config_file.path()); 164 | assert!(res.is_ok()); 165 | let config = res.unwrap(); 166 | 167 | assert_eq!(config.filetype.len(), 2); 168 | assert_eq!(config.handler_preview.len(), 1); 169 | assert_eq!( 170 | config.default_handler_preview, 171 | FileHandler { 172 | command: "file %i".to_owned(), 173 | wait: true, 174 | shell: false, 175 | no_pipe: false, 176 | stdin_arg: None 177 | } 178 | ); 179 | assert_eq!(config.handler_open.len(), 1); 180 | assert_eq!( 181 | config.default_handler_open, 182 | FileHandler { 183 | command: "cat -A %i".to_owned(), 184 | wait: true, 185 | shell: false, 186 | no_pipe: false, 187 | stdin_arg: None 188 | } 189 | ); 190 | assert_eq!(config.filter.len(), 1); 191 | } 192 | 193 | #[test] 194 | fn test_advanced_config() { 195 | const ADVANCED_CONFIG_STR: &str = include_str!("../config/config.toml.advanced"); 196 | let mut config_file = tempfile::NamedTempFile::new().unwrap(); 197 | config_file 198 | .write_all(ADVANCED_CONFIG_STR.as_bytes()) 199 | .unwrap(); 200 | 201 | let res = parse_config_path(config_file.path()); 202 | assert!(res.is_ok()); 203 | let config = res.unwrap(); 204 | 205 | assert_eq!(config.filetype.len(), 35); 206 | assert_eq!(config.handler_preview.len(), 25); 207 | assert_eq!( 208 | config.default_handler_preview, 209 | FileHandler { 210 | command: "echo '🔍 MIME: %m'; hexyl --border none %i | head -n $((%l - 1))" 211 | .to_owned(), 212 | wait: true, 213 | shell: true, 214 | no_pipe: false, 215 | stdin_arg: Some(String::new()) 216 | } 217 | ); 218 | assert_eq!(config.handler_open.len(), 20); 219 | assert_eq!( 220 | config.default_handler_open, 221 | FileHandler { 222 | command: "hexyl %i | less -R".to_owned(), 223 | wait: true, 224 | shell: true, 225 | no_pipe: false, 226 | stdin_arg: Some(String::new()) 227 | } 228 | ); 229 | assert_eq!(config.filter.len(), 5); 230 | } 231 | } 232 | -------------------------------------------------------------------------------- /src/handler.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | collections::HashMap, 3 | env, 4 | fs::File, 5 | io::{self, copy, stdin, Read, Write}, 6 | iter, 7 | os::unix::{ 8 | fs::FileTypeExt, 9 | io::{AsRawFd, FromRawFd}, 10 | }, 11 | path::{Path, PathBuf}, 12 | process::{Child, Command, Stdio}, 13 | rc::Rc, 14 | }; 15 | 16 | use anyhow::Context as _; 17 | 18 | use crate::{ 19 | config, 20 | config::{FileFilter, FileHandler, SchemeHandler}, 21 | RsopMode, 22 | }; 23 | 24 | #[derive(Debug)] 25 | enum FileProcessor { 26 | Filter(FileFilter), 27 | Handler(FileHandler), 28 | } 29 | 30 | enum PipeOrTmpFile { 31 | Pipe(T), 32 | TmpFile(tempfile::NamedTempFile), 33 | } 34 | 35 | impl FileProcessor { 36 | /// Return true if command string contains a given % prefixed pattern 37 | fn has_pattern(&self, pattern: char) -> bool { 38 | let re_str = format!("[^%]%{pattern}"); 39 | #[expect(clippy::unwrap_used)] 40 | let re = regex::Regex::new(&re_str).unwrap(); 41 | let command = match self { 42 | FileProcessor::Filter(f) => &f.command, 43 | FileProcessor::Handler(h) => &h.command, 44 | }; 45 | re.is_match(command) 46 | } 47 | } 48 | 49 | #[derive(Debug)] 50 | struct FileHandlers { 51 | extensions: HashMap>, 52 | mimes: HashMap>, 53 | default: FileHandler, 54 | } 55 | 56 | impl FileHandlers { 57 | pub(crate) fn new(default: &FileHandler) -> FileHandlers { 58 | FileHandlers { 59 | extensions: HashMap::new(), 60 | mimes: HashMap::new(), 61 | default: default.clone(), 62 | } 63 | } 64 | 65 | pub(crate) fn add(&mut self, processor: &Rc, filetype: &config::Filetype) { 66 | for extension in &filetype.extensions { 67 | self.extensions 68 | .insert(extension.clone(), Rc::clone(processor)); 69 | } 70 | for mime in &filetype.mimes { 71 | self.mimes.insert(mime.clone(), Rc::clone(processor)); 72 | } 73 | } 74 | } 75 | 76 | #[derive(Debug)] 77 | struct SchemeHandlers { 78 | schemes: HashMap, 79 | } 80 | 81 | impl SchemeHandlers { 82 | pub(crate) fn new() -> SchemeHandlers { 83 | SchemeHandlers { 84 | schemes: HashMap::new(), 85 | } 86 | } 87 | 88 | pub(crate) fn add(&mut self, handler: &SchemeHandler, scheme: &str) { 89 | self.schemes.insert(scheme.to_owned(), handler.clone()); 90 | } 91 | } 92 | 93 | #[derive(Debug)] 94 | pub(crate) struct HandlerMapping { 95 | preview: FileHandlers, 96 | open: FileHandlers, 97 | edit: FileHandlers, 98 | scheme: SchemeHandlers, 99 | } 100 | 101 | #[derive(thiserror::Error, Debug)] 102 | pub(crate) enum HandlerError { 103 | #[error("Failed to run handler command {:?}: {err}", .cmd.connect(" "))] 104 | Start { err: io::Error, cmd: Vec }, 105 | #[error("Failed to read input file {path:?}: {err}")] 106 | Input { err: io::Error, path: PathBuf }, 107 | #[error(transparent)] 108 | Io(#[from] io::Error), 109 | #[error(transparent)] 110 | Other(#[from] anyhow::Error), 111 | } 112 | 113 | /// How many bytes to read from pipe to guess MIME type, use a full memory page 114 | const PIPE_INITIAL_READ_LENGTH: usize = 4096; 115 | 116 | impl HandlerMapping { 117 | #[expect(clippy::similar_names)] 118 | pub(crate) fn new(cfg: &config::Config) -> anyhow::Result { 119 | let mut handlers_open = FileHandlers::new(&cfg.default_handler_open); 120 | let mut handlers_edit = FileHandlers::new(&cfg.default_handler_open); 121 | let mut handlers_preview = FileHandlers::new(&cfg.default_handler_preview); 122 | for (name, filetype) in &cfg.filetype { 123 | let handler_open = cfg.handler_open.get(name).cloned(); 124 | let handler_edit = cfg.handler_edit.get(name).cloned(); 125 | let handler_preview = cfg.handler_preview.get(name).cloned(); 126 | let filter = cfg.filter.get(name).cloned(); 127 | anyhow::ensure!( 128 | handler_open.is_some() 129 | || handler_edit.is_some() 130 | || handler_preview.is_some() 131 | || filter.is_some(), 132 | "Filetype {} is not bound to any handler or filter", 133 | name 134 | ); 135 | if let Some(handler_open) = handler_open { 136 | Self::validate_handler(&handler_open)?; 137 | handlers_open.add(&Rc::new(FileProcessor::Handler(handler_open)), filetype); 138 | } 139 | if let Some(handler_edit) = handler_edit { 140 | Self::validate_handler(&handler_edit)?; 141 | handlers_edit.add(&Rc::new(FileProcessor::Handler(handler_edit)), filetype); 142 | } 143 | if let Some(handler_preview) = handler_preview { 144 | Self::validate_handler(&handler_preview)?; 145 | handlers_preview.add(&Rc::new(FileProcessor::Handler(handler_preview)), filetype); 146 | } 147 | if let Some(filter) = filter { 148 | anyhow::ensure!( 149 | filter.no_pipe || (Self::count_pattern(&filter.command, 'i') <= 1), 150 | "Filter {:?} can not have both 'no_pipe = false' and multiple %i in command", 151 | filter 152 | ); 153 | let proc_filter = Rc::new(FileProcessor::Filter(filter)); 154 | handlers_open.add(&Rc::clone(&proc_filter), filetype); 155 | handlers_edit.add(&Rc::clone(&proc_filter), filetype); 156 | handlers_preview.add(&Rc::clone(&proc_filter), filetype); 157 | } 158 | } 159 | 160 | let mut handlers_scheme = SchemeHandlers::new(); 161 | for (schemes, handler) in &cfg.handler_scheme { 162 | handlers_scheme.add(handler, schemes); 163 | } 164 | 165 | Ok(HandlerMapping { 166 | preview: handlers_preview, 167 | open: handlers_open, 168 | edit: handlers_edit, 169 | scheme: handlers_scheme, 170 | }) 171 | } 172 | 173 | fn validate_handler(handler: &FileHandler) -> anyhow::Result<()> { 174 | anyhow::ensure!( 175 | !handler.no_pipe || handler.wait, 176 | "Handler {:?} can not have both 'no_pipe = true' and 'wait = false'", 177 | handler 178 | ); 179 | anyhow::ensure!( 180 | handler.no_pipe || (Self::count_pattern(&handler.command, 'i') <= 1), 181 | "Handler {:?} can not have both 'no_pipe = false' and multiple %i in command", 182 | handler 183 | ); 184 | Ok(()) 185 | } 186 | 187 | /// Count number of a given % prefixed pattern in command string 188 | fn count_pattern(command: &str, pattern: char) -> usize { 189 | let re_str = format!("[^%]%{pattern}"); 190 | #[expect(clippy::unwrap_used)] 191 | let re = regex::Regex::new(&re_str).unwrap(); 192 | re.find_iter(command).count() 193 | } 194 | 195 | pub(crate) fn handle_path(&self, mode: &RsopMode, path: &Path) -> Result<(), HandlerError> { 196 | if let (RsopMode::XdgOpen, Ok(url)) = ( 197 | &mode, 198 | url::Url::parse( 199 | path.to_str() 200 | .ok_or_else(|| anyhow::anyhow!("Unable to decode path {:?}", path))?, 201 | ), 202 | ) { 203 | if url.scheme() == "file" { 204 | let url_path = &url[url::Position::BeforeUsername..]; 205 | let parsed_path = PathBuf::from(url_path); 206 | log::trace!("url={}, parsed_path={:?}", url, parsed_path); 207 | self.dispatch_path(&parsed_path, mode) 208 | } else { 209 | self.dispatch_url(&url) 210 | } 211 | } else { 212 | self.dispatch_path(path, mode) 213 | } 214 | } 215 | 216 | pub(crate) fn handle_pipe(&self, mode: &RsopMode) -> Result<(), HandlerError> { 217 | let stdin = Self::stdin_reader(); 218 | self.dispatch_pipe(stdin, mode) 219 | } 220 | 221 | fn path_mime(path: &Path) -> Result, io::Error> { 222 | // Rather than read socket/pipe, mimic 'file -ib xxx' behavior and return 'inode/yyy' strings 223 | let metadata = path.metadata()?; 224 | let file_type = metadata.file_type(); 225 | let mime = if file_type.is_socket() { 226 | Some("inode/socket") 227 | } else if file_type.is_fifo() { 228 | Some("inode/fifo") 229 | } else { 230 | // tree_magic_mini::from_filepath returns Option and not a Result<_, io::Error> 231 | // so probe first to properly propagate the proper error cause 232 | File::open(path)?; 233 | tree_magic_mini::from_filepath(path) 234 | }; 235 | log::debug!("MIME: {:?}", mime); 236 | 237 | Ok(mime) 238 | } 239 | 240 | #[expect(clippy::wildcard_in_or_patterns)] 241 | fn dispatch_path(&self, path: &Path, mode: &RsopMode) -> Result<(), HandlerError> { 242 | // Handler candidates, with fallbacks 243 | let (mode_handlers, next_handlers) = match mode { 244 | RsopMode::Preview => (&self.preview, None), 245 | RsopMode::Edit => (&self.edit, Some(&self.open)), 246 | RsopMode::Open | _ => (&self.open, Some(&self.edit)), 247 | }; 248 | 249 | // Try by extension first 250 | if *mode != RsopMode::Identify { 251 | for handlers in iter::once(mode_handlers).chain(next_handlers) { 252 | for extension in Self::path_extensions(path)? { 253 | if let Some(handler) = handlers.extensions.get(&extension) { 254 | let mime = if handler.has_pattern('m') { 255 | // Probe MIME type even if we already found a handler, to substitute in command 256 | Self::path_mime(path).map_err(|e| HandlerError::Input { 257 | err: e, 258 | path: path.to_owned(), 259 | })? 260 | } else { 261 | None 262 | }; 263 | return self.run_path(handler, path, mode, mime); 264 | } 265 | } 266 | } 267 | } 268 | 269 | let mime = Self::path_mime(path).map_err(|e| HandlerError::Input { 270 | err: e, 271 | path: path.to_owned(), 272 | })?; 273 | if let RsopMode::Identify = mode { 274 | println!( 275 | "{}", 276 | mime.ok_or_else(|| anyhow::anyhow!("Unable to get MIME type for {:?}", path))? 277 | ); 278 | return Ok(()); 279 | } 280 | 281 | // Match by MIME 282 | for handlers in iter::once(mode_handlers).chain(next_handlers) { 283 | if let Some(mime) = mime { 284 | // Try sub MIME types 285 | for sub_mime in Self::split_mime(mime) { 286 | log::trace!("Trying MIME {sub_mime:?}"); 287 | if let Some(handler) = handlers.mimes.get(&sub_mime) { 288 | return self.run_path(handler, path, mode, Some(&sub_mime)); 289 | } 290 | } 291 | } 292 | } 293 | 294 | // Fallback 295 | self.run_path( 296 | &FileProcessor::Handler(mode_handlers.default.clone()), 297 | path, 298 | mode, 299 | mime, 300 | ) 301 | } 302 | 303 | #[expect(clippy::wildcard_in_or_patterns)] 304 | fn dispatch_pipe(&self, mut pipe: T, mode: &RsopMode) -> Result<(), HandlerError> 305 | where 306 | T: Read + Send, 307 | { 308 | // Handler candidates 309 | let (mode_handlers, next_handlers) = match mode { 310 | RsopMode::Preview => (&self.preview, None), 311 | RsopMode::Edit => (&self.edit, Some(&self.open)), 312 | RsopMode::Open | _ => (&self.open, Some(&self.edit)), 313 | }; 314 | 315 | // Read header 316 | log::trace!( 317 | "Using max header length of {} bytes", 318 | PIPE_INITIAL_READ_LENGTH 319 | ); 320 | let mut buffer: Vec = vec![0; PIPE_INITIAL_READ_LENGTH]; 321 | let header_len = pipe.read(&mut buffer)?; 322 | let header = &buffer[0..header_len]; 323 | 324 | let mime = tree_magic_mini::from_u8(header); 325 | log::debug!("MIME: {:?}", mime); 326 | if let RsopMode::Identify = mode { 327 | println!("{mime}"); 328 | return Ok(()); 329 | } 330 | 331 | for handlers in iter::once(mode_handlers).chain(next_handlers) { 332 | // Try sub MIME types 333 | for sub_mime in Self::split_mime(mime) { 334 | log::trace!("Trying MIME {sub_mime:?}"); 335 | if let Some(handler) = handlers.mimes.get(&sub_mime) { 336 | return self.run_pipe(handler, header, pipe, Some(&sub_mime), mode); 337 | } 338 | } 339 | } 340 | 341 | // Fallback 342 | self.run_pipe( 343 | &FileProcessor::Handler(mode_handlers.default.clone()), 344 | header, 345 | pipe, 346 | Some(mime), 347 | mode, 348 | ) 349 | } 350 | 351 | fn dispatch_url(&self, url: &url::Url) -> Result<(), HandlerError> { 352 | let scheme = url.scheme(); 353 | if let Some(handler) = self.scheme.schemes.get(scheme) { 354 | return Self::run_url(handler, url); 355 | } 356 | 357 | Err(HandlerError::Other(anyhow::anyhow!( 358 | "No handler for scheme {:?}", 359 | scheme 360 | ))) 361 | } 362 | 363 | // Substitute % prefixed patterns in string 364 | fn substitute( 365 | s: &str, 366 | path: &Path, 367 | mime: Option<&str>, 368 | term_size: (u16, u16), 369 | ) -> anyhow::Result { 370 | const BASE_SUBST_REGEX: &str = "([^%])(%{})"; 371 | const BASE_SUBST_UNESCAPE_SRC: &str = "%%"; 372 | const BASE_SUBST_UNESCAPE_DST: &str = "%"; 373 | 374 | let mut r = s.to_owned(); 375 | 376 | let mut path_arg = path 377 | .to_str() 378 | .ok_or_else(|| anyhow::anyhow!("Invalid path {path:?}"))? 379 | .to_owned(); 380 | if !path_arg.is_empty() { 381 | path_arg = shlex::try_quote(&path_arg) 382 | .with_context(|| format!("Failed to quote string {path_arg:?}"))? 383 | .to_string(); 384 | } 385 | 386 | let mut subst_params: Vec<(String, &str, &str, &str)> = vec![ 387 | ( 388 | format!("{}", term_size.0), 389 | const_format::str_replace!(BASE_SUBST_REGEX, "{}", "c"), 390 | const_format::concatcp!(BASE_SUBST_UNESCAPE_SRC, 'c'), 391 | const_format::concatcp!(BASE_SUBST_UNESCAPE_DST, 'c'), 392 | ), 393 | ( 394 | format!("{}", term_size.1), 395 | const_format::str_replace!(BASE_SUBST_REGEX, "{}", "l"), 396 | const_format::concatcp!(BASE_SUBST_UNESCAPE_SRC, 'l'), 397 | const_format::concatcp!(BASE_SUBST_UNESCAPE_DST, 'l'), 398 | ), 399 | ( 400 | path_arg, 401 | const_format::str_replace!(BASE_SUBST_REGEX, "{}", "i"), 402 | const_format::concatcp!(BASE_SUBST_UNESCAPE_SRC, 'i'), 403 | const_format::concatcp!(BASE_SUBST_UNESCAPE_DST, 'i'), 404 | ), 405 | ]; 406 | if let Some(mime) = mime { 407 | subst_params.push(( 408 | mime.to_owned(), 409 | const_format::str_replace!(BASE_SUBST_REGEX, "{}", "m"), 410 | const_format::concatcp!(BASE_SUBST_UNESCAPE_SRC, 'm'), 411 | const_format::concatcp!(BASE_SUBST_UNESCAPE_DST, 'm'), 412 | )); 413 | } 414 | for (val, re_str, unescape_src, unescape_dst) in subst_params { 415 | #[expect(clippy::unwrap_used)] 416 | let re = regex::Regex::new(re_str).unwrap(); 417 | r = re.replace_all(&r, format!("${{1}}{val}")).to_string(); 418 | r = r.replace(unescape_src, unescape_dst); 419 | } 420 | 421 | Ok(r.trim().to_owned()) 422 | } 423 | 424 | // Get terminal size by probing it, reading it from env, or using fallback 425 | fn term_size() -> (u16, u16) { 426 | termion::terminal_size().unwrap_or_else(|_| { 427 | let cols_env = env::var("FZF_PREVIEW_COLUMNS") 428 | .ok() 429 | .and_then(|v| v.parse::().ok()) 430 | .or_else(|| env::var("COLUMNS").ok().and_then(|v| v.parse::().ok())); 431 | let rows_env = env::var("FZF_PREVIEW_LINES") 432 | .ok() 433 | .and_then(|v| v.parse::().ok()) 434 | .or_else(|| env::var("LINES").ok().and_then(|v| v.parse::().ok())); 435 | if let (Some(cols), Some(rows)) = (cols_env, rows_env) { 436 | (cols, rows) 437 | } else { 438 | (80, 24) 439 | } 440 | }) 441 | } 442 | 443 | fn run_path( 444 | &self, 445 | processor: &FileProcessor, 446 | path: &Path, 447 | mode: &RsopMode, 448 | mime: Option<&str>, 449 | ) -> Result<(), HandlerError> { 450 | let term_size = Self::term_size(); 451 | 452 | match processor { 453 | FileProcessor::Handler(handler) => { 454 | Self::run_path_handler(handler, path, mime, term_size) 455 | } 456 | FileProcessor::Filter(filter) => { 457 | let mut filter_child = Self::run_path_filter(filter, path, mime, term_size)?; 458 | #[expect(clippy::unwrap_used)] 459 | let r = self.dispatch_pipe(filter_child.stdout.take().unwrap(), mode); 460 | filter_child.kill()?; 461 | filter_child.wait()?; 462 | r 463 | } 464 | } 465 | } 466 | 467 | fn run_path_filter( 468 | filter: &FileFilter, 469 | path: &Path, 470 | mime: Option<&str>, 471 | term_size: (u16, u16), 472 | ) -> Result { 473 | let cmd = Self::substitute(&filter.command, path, mime, term_size)?; 474 | let cmd_args = Self::build_cmd(&cmd, filter.shell)?; 475 | 476 | let mut command = Command::new(&cmd_args[0]); 477 | command 478 | .args(&cmd_args[1..]) 479 | .stdin(Stdio::null()) 480 | .stdout(Stdio::piped()) 481 | .spawn() 482 | .map_err(|e| HandlerError::Start { 483 | err: e, 484 | cmd: cmd_args.clone(), 485 | }) 486 | } 487 | 488 | fn run_path_handler( 489 | handler: &FileHandler, 490 | path: &Path, 491 | mime: Option<&str>, 492 | term_size: (u16, u16), 493 | ) -> Result<(), HandlerError> { 494 | let cmd = Self::substitute(&handler.command, path, mime, term_size)?; 495 | let cmd_args = Self::build_cmd(&cmd, handler.shell)?; 496 | 497 | let mut command = Command::new(&cmd_args[0]); 498 | command.args(&cmd_args[1..]).stdin(Stdio::null()); 499 | if handler.wait { 500 | command 501 | .status() 502 | .map(|_| ()) 503 | .map_err(|e| HandlerError::Start { 504 | err: e, 505 | cmd: cmd_args.clone(), 506 | }) 507 | } else { 508 | command.stdout(Stdio::null()); 509 | command.stderr(Stdio::null()); 510 | command 511 | .spawn() 512 | .map(|_| ()) 513 | .map_err(|e| HandlerError::Start { 514 | err: e, 515 | cmd: cmd_args.clone(), 516 | }) 517 | } 518 | } 519 | 520 | fn run_pipe( 521 | &self, 522 | processor: &FileProcessor, 523 | header: &[u8], 524 | pipe: T, 525 | mime: Option<&str>, 526 | mode: &RsopMode, 527 | ) -> Result<(), HandlerError> 528 | where 529 | T: Read + Send, 530 | { 531 | let term_size = Self::term_size(); 532 | 533 | match processor { 534 | FileProcessor::Handler(handler) => { 535 | Self::run_pipe_handler(handler, header, pipe, mime, term_size) 536 | } 537 | FileProcessor::Filter(filter) => crossbeam_utils::thread::scope(|scope| { 538 | // Write to a temporary file if filter does not support reading from stdin 539 | let input = if filter.no_pipe { 540 | PipeOrTmpFile::TmpFile(Self::pipe_to_tmpfile(header, pipe)?) 541 | } else { 542 | PipeOrTmpFile::Pipe(pipe) 543 | }; 544 | 545 | // Run 546 | let tmp_file = if let PipeOrTmpFile::TmpFile(tmp_file) = &input { 547 | Some(tmp_file) 548 | } else { 549 | None 550 | }; 551 | let mut filter_child = Self::run_pipe_filter(filter, mime, tmp_file, term_size)?; 552 | #[expect(clippy::unwrap_used)] 553 | let filter_child_stdout = filter_child.stdout.take().unwrap(); 554 | 555 | #[expect(clippy::shadow_unrelated)] 556 | if let PipeOrTmpFile::Pipe(mut pipe) = input { 557 | // Send data to filter 558 | #[expect(clippy::unwrap_used)] 559 | let mut filter_child_stdin = filter_child.stdin.take().unwrap(); 560 | scope.spawn(move |_| { 561 | Self::pipe_forward(&mut pipe, &mut filter_child_stdin, header) 562 | }); 563 | } 564 | 565 | // Dispatch to next handler/filter 566 | let r = self.dispatch_pipe(filter_child_stdout, mode); 567 | 568 | // Cleanup 569 | filter_child.kill()?; 570 | filter_child.wait()?; 571 | 572 | r 573 | }) 574 | .map_err(|e| anyhow::anyhow!("Worker thread error: {:?}", e))?, 575 | } 576 | } 577 | 578 | fn run_pipe_filter( 579 | filter: &FileFilter, 580 | mime: Option<&str>, 581 | tmp_file: Option<&tempfile::NamedTempFile>, 582 | term_size: (u16, u16), 583 | ) -> Result { 584 | // Build command 585 | let path = if let Some(tmp_file) = tmp_file { 586 | tmp_file.path().to_path_buf() 587 | } else if let Some(stdin_arg) = &filter.stdin_arg { 588 | PathBuf::from(stdin_arg) 589 | } else { 590 | PathBuf::from("-") 591 | }; 592 | let cmd = Self::substitute(&filter.command, &path, mime, term_size)?; 593 | let cmd_args = Self::build_cmd(&cmd, filter.shell)?; 594 | 595 | // Run 596 | let mut command = Command::new(&cmd_args[0]); 597 | command.args(&cmd_args[1..]); 598 | if tmp_file.is_none() { 599 | command.stdin(Stdio::piped()); 600 | } else { 601 | command 602 | .stdin(Stdio::null()) 603 | .env("RSOP_INPUT_IS_STDIN_COPY", "1"); 604 | } 605 | command.stdout(Stdio::piped()); 606 | let child = command.spawn().map_err(|e| HandlerError::Start { 607 | err: e, 608 | cmd: cmd_args.clone(), 609 | })?; 610 | Ok(child) 611 | } 612 | 613 | fn run_pipe_handler( 614 | handler: &FileHandler, 615 | header: &[u8], 616 | pipe: T, 617 | mime: Option<&str>, 618 | term_size: (u16, u16), 619 | ) -> Result<(), HandlerError> 620 | where 621 | T: Read, 622 | { 623 | // Write to a temporary file if handler does not support reading from stdin 624 | let input = if handler.no_pipe { 625 | PipeOrTmpFile::TmpFile(Self::pipe_to_tmpfile(header, pipe)?) 626 | } else { 627 | PipeOrTmpFile::Pipe(pipe) 628 | }; 629 | 630 | // Build command 631 | let path = if let PipeOrTmpFile::TmpFile(tmp_file) = &input { 632 | tmp_file.path().to_path_buf() 633 | } else if let Some(stdin_arg) = &handler.stdin_arg { 634 | PathBuf::from(stdin_arg) 635 | } else { 636 | PathBuf::from("-") 637 | }; 638 | let cmd = Self::substitute(&handler.command, &path, mime, term_size)?; 639 | let cmd_args = Self::build_cmd(&cmd, handler.shell)?; 640 | 641 | // Run 642 | let mut command = Command::new(&cmd_args[0]); 643 | command.args(&cmd_args[1..]); 644 | if let PipeOrTmpFile::Pipe(_) = input { 645 | command.stdin(Stdio::piped()); 646 | } else { 647 | command 648 | .stdin(Stdio::null()) 649 | .env("RSOP_INPUT_IS_STDIN_COPY", "1"); 650 | } 651 | if !handler.wait { 652 | command.stdout(Stdio::null()); 653 | command.stderr(Stdio::null()); 654 | } 655 | let mut child = command.spawn().map_err(|e| HandlerError::Start { 656 | err: e, 657 | cmd: cmd_args.clone(), 658 | })?; 659 | 660 | #[expect(clippy::shadow_unrelated)] 661 | if let PipeOrTmpFile::Pipe(mut pipe) = input { 662 | // Send data to handler 663 | #[expect(clippy::unwrap_used)] 664 | let mut child_stdin = child.stdin.take().unwrap(); 665 | Self::pipe_forward(&mut pipe, &mut child_stdin, header)?; 666 | drop(child_stdin); 667 | } 668 | 669 | if handler.wait || handler.no_pipe { 670 | child.wait()?; 671 | } 672 | 673 | Ok(()) 674 | } 675 | 676 | fn run_url(handler: &SchemeHandler, url: &url::Url) -> Result<(), HandlerError> { 677 | let term_size = Self::term_size(); 678 | 679 | // Build command 680 | let path: PathBuf = PathBuf::from(url.to_owned().as_str()); 681 | let cmd = Self::substitute(&handler.command, &path, None, term_size)?; 682 | let cmd_args = Self::build_cmd(&cmd, handler.shell)?; 683 | 684 | // Run 685 | let mut command = Command::new(&cmd_args[0]); 686 | command.args(&cmd_args[1..]); 687 | // To mimic xdg-open, close all input/outputs and detach 688 | command.stdin(Stdio::null()); 689 | command.stdout(Stdio::null()); 690 | command.stderr(Stdio::null()); 691 | command.spawn().map_err(|e| HandlerError::Start { 692 | err: e, 693 | cmd: cmd_args.clone(), 694 | })?; 695 | 696 | Ok(()) 697 | } 698 | 699 | fn stdin_reader() -> File { 700 | let stdin = stdin(); 701 | // SAFETY: 702 | // Unfortunately, stdin is buffered, and there is no clean way to get it 703 | // unbuffered to read only what we want for the header, so use fd hack to get an unbuffered reader 704 | // see https://users.rust-lang.org/t/add-unbuffered-rawstdin-rawstdout/26013 705 | unsafe { File::from_raw_fd(stdin.as_raw_fd()) } 706 | } 707 | 708 | fn pipe_forward(src: &mut S, dst: &mut D, header: &[u8]) -> anyhow::Result 709 | where 710 | S: Read, 711 | D: Write, 712 | { 713 | dst.write_all(header)?; 714 | log::trace!("Header written ({} bytes)", header.len()); 715 | 716 | #[expect(clippy::cast_possible_truncation)] 717 | let copied = copy(src, dst)? as usize; 718 | log::trace!( 719 | "Pipe exhausted, moved {} bytes total", 720 | header.len() + copied 721 | ); 722 | 723 | Ok(header.len() + copied) 724 | } 725 | 726 | fn pipe_to_tmpfile(header: &[u8], mut pipe: T) -> anyhow::Result 727 | where 728 | T: Read, 729 | { 730 | let mut tmp_file = tempfile::Builder::new() 731 | .prefix(const_format::concatcp!(env!("CARGO_PKG_NAME"), '_')) 732 | .tempfile()?; 733 | log::debug!("Writing to temporary file {:?}", tmp_file.path()); 734 | let file = tmp_file.as_file_mut(); 735 | Self::pipe_forward(&mut pipe, file, header)?; 736 | file.flush()?; 737 | Ok(tmp_file) 738 | } 739 | 740 | fn build_cmd(cmd: &str, shell: bool) -> anyhow::Result> { 741 | let cmd = if shell { 742 | vec!["sh".to_owned(), "-c".to_owned(), cmd.to_owned()] 743 | } else { 744 | shlex::split(cmd).ok_or_else(|| anyhow::anyhow!("Invalid command {:?}", cmd))? 745 | }; 746 | log::debug!("Will run command: {:?}", cmd); 747 | Ok(cmd) 748 | } 749 | 750 | fn path_extensions(path: &Path) -> anyhow::Result> { 751 | let mut extensions = Vec::new(); 752 | if let Some(extension) = path.extension() { 753 | // Try to get double extension first if we have one 754 | let filename = path 755 | .file_name() 756 | .and_then(|f| f.to_str()) 757 | .ok_or_else(|| anyhow::anyhow!("Unable to get file name from path {:?}", path))?; 758 | let double_ext_parts: Vec<_> = filename 759 | .split('.') 760 | .skip(1) 761 | .collect::>() 762 | .into_iter() 763 | .rev() 764 | .take(2) 765 | .collect::>() 766 | .into_iter() 767 | .rev() 768 | .collect(); 769 | if double_ext_parts.len() == 2 { 770 | extensions.push(double_ext_parts.join(".").to_lowercase()); 771 | } 772 | extensions.push( 773 | extension 774 | .to_str() 775 | .ok_or_else(|| { 776 | anyhow::anyhow!("Unable to decode extension for path {:?}", path) 777 | })? 778 | .to_lowercase(), 779 | ); 780 | } 781 | Ok(extensions) 782 | } 783 | 784 | fn split_mime(s: &str) -> Vec { 785 | let mut r = vec![s.to_owned()]; 786 | let mut base = s.to_owned(); 787 | if let Some((a, _b)) = base.rsplit_once('+') { 788 | r.push(a.to_owned()); 789 | base = a.to_owned(); 790 | } 791 | for (dot_idx, _) in base.rmatch_indices('.') { 792 | #[expect(clippy::string_slice)] 793 | r.push(base[..dot_idx].to_string()); 794 | } 795 | if let Some((a, _b)) = base.split_once('/') { 796 | r.push(a.to_owned()); 797 | } 798 | r 799 | } 800 | } 801 | 802 | #[cfg(test)] 803 | mod tests { 804 | use super::*; 805 | 806 | #[test] 807 | fn test_has_pattern() { 808 | let mut handler = FileHandler { 809 | command: "a ii".to_owned(), 810 | wait: false, 811 | shell: false, 812 | no_pipe: false, 813 | stdin_arg: Some(String::new()), 814 | }; 815 | let mut processor = FileProcessor::Handler(handler.clone()); 816 | assert!(!processor.has_pattern('m')); 817 | assert!(!processor.has_pattern('i')); 818 | 819 | handler.command = "a %i".to_owned(); 820 | processor = FileProcessor::Handler(handler.clone()); 821 | assert!(!processor.has_pattern('m')); 822 | assert!(processor.has_pattern('i')); 823 | 824 | handler.command = "a %%i".to_owned(); 825 | processor = FileProcessor::Handler(handler); 826 | assert!(!processor.has_pattern('m')); 827 | assert!(!processor.has_pattern('i')); 828 | } 829 | 830 | #[test] 831 | fn test_count_pattern() { 832 | assert_eq!(HandlerMapping::count_pattern("aa ii ii", 'm'), 0); 833 | assert_eq!(HandlerMapping::count_pattern("aa ii ii", 'i'), 0); 834 | 835 | assert_eq!(HandlerMapping::count_pattern("a %i", 'm'), 0); 836 | assert_eq!(HandlerMapping::count_pattern("a %i", 'i'), 1); 837 | 838 | assert_eq!(HandlerMapping::count_pattern("a %i %i %m", 'm'), 1); 839 | assert_eq!(HandlerMapping::count_pattern("a %i %i %m", 'i'), 2); 840 | 841 | assert_eq!(HandlerMapping::count_pattern("a %%i %i %%m", 'm'), 0); 842 | assert_eq!(HandlerMapping::count_pattern("a %%i %i %%m", 'i'), 1); 843 | } 844 | 845 | #[test] 846 | fn test_substitute() { 847 | let term_size = (85, 84); 848 | let path = Path::new(""); 849 | 850 | assert_eq!( 851 | HandlerMapping::substitute("abc def", path, None, term_size).unwrap(), 852 | "abc def" 853 | ); 854 | assert_eq!( 855 | HandlerMapping::substitute("ab%%c def", path, None, term_size).unwrap(), 856 | "ab%c def" 857 | ); 858 | assert_eq!( 859 | HandlerMapping::substitute("ab%c def", path, None, term_size).unwrap(), 860 | "ab85 def" 861 | ); 862 | } 863 | 864 | #[test] 865 | fn test_path_extensions() { 866 | assert_eq!( 867 | HandlerMapping::path_extensions(Path::new("/tmp/")).ok(), 868 | Some(vec![]) 869 | ); 870 | assert_eq!( 871 | HandlerMapping::path_extensions(Path::new("/tmp/foo")).ok(), 872 | Some(vec![]) 873 | ); 874 | assert_eq!( 875 | HandlerMapping::path_extensions(Path::new("/tmp/foo.bar")).ok(), 876 | Some(vec!["bar".to_owned()]) 877 | ); 878 | assert_eq!( 879 | HandlerMapping::path_extensions(Path::new("/tmp/foo.bar.baz")).ok(), 880 | Some(vec!["bar.baz".to_owned(), "baz".to_owned()]) 881 | ); 882 | assert_eq!( 883 | HandlerMapping::path_extensions(Path::new("/tmp/foo.BaR.bAz")).ok(), 884 | Some(vec!["bar.baz".to_owned(), "baz".to_owned()]) 885 | ); 886 | assert_eq!( 887 | HandlerMapping::path_extensions(Path::new("/tmp/foo.bar.baz.blah")).ok(), 888 | Some(vec!["baz.blah".to_owned(), "blah".to_owned()]) 889 | ); 890 | } 891 | 892 | #[test] 893 | fn test_split_mime() { 894 | assert_eq!( 895 | HandlerMapping::split_mime("application/vnd.debian.binary-package"), 896 | vec![ 897 | "application/vnd.debian.binary-package", 898 | "application/vnd.debian", 899 | "application/vnd", 900 | "application" 901 | ] 902 | ); 903 | 904 | assert_eq!( 905 | HandlerMapping::split_mime("application/pkix-cert+pem"), 906 | vec![ 907 | "application/pkix-cert+pem", 908 | "application/pkix-cert", 909 | "application" 910 | ] 911 | ); 912 | } 913 | } 914 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | //! RSOP 2 | 3 | use std::{collections::BTreeMap, env, path::Path, str::FromStr, sync::LazyLock}; 4 | 5 | use anyhow::Context; 6 | use clap::Parser; 7 | use strum::VariantNames; 8 | 9 | mod cli; 10 | mod config; 11 | mod handler; 12 | 13 | #[derive( 14 | Clone, Debug, Default, Eq, PartialEq, strum::Display, strum::EnumString, strum::VariantNames, 15 | )] 16 | #[strum(ascii_case_insensitive)] 17 | #[strum(serialize_all = "kebab-case")] 18 | pub(crate) enum RsopMode { 19 | Preview, 20 | #[default] 21 | Open, 22 | XdgOpen, 23 | Edit, 24 | Identify, 25 | } 26 | 27 | static BIN_NAME_TO_MODE: LazyLock> = LazyLock::new(|| { 28 | BTreeMap::from([ 29 | ("rsp", RsopMode::Preview), 30 | ("rso", RsopMode::Open), 31 | ("xdg-open", RsopMode::XdgOpen), 32 | ("rse", RsopMode::Edit), 33 | ("rsi", RsopMode::Identify), 34 | ]) 35 | }); 36 | 37 | fn runtime_mode() -> anyhow::Result { 38 | // Get from env var 39 | let env_mode = env::var("RSOP_MODE"); 40 | if let Ok(env_mode) = env_mode { 41 | return RsopMode::from_str(&env_mode) 42 | .with_context(|| format!("Unexpected value for RSOP_MODE: {env_mode:?}")); 43 | } 44 | 45 | // Get from binary name (env::current_exe() follows symbolic links, so don't use it) 46 | let first_arg = env::args() 47 | .next() 48 | .ok_or_else(|| anyhow::anyhow!("Unable to get current binary path"))?; 49 | let bin_name: Option<&str> = Path::new(&first_arg) 50 | .file_name() 51 | .map(|f| f.to_str()) 52 | .ok_or_else(|| anyhow::anyhow!("Unable to get current binary filename"))?; 53 | if let Some(bin_name) = bin_name { 54 | if let Some(mode) = BIN_NAME_TO_MODE.get(bin_name) { 55 | return Ok(mode.to_owned()); 56 | } 57 | } 58 | 59 | let mut sorted_variants = Vec::from(RsopMode::VARIANTS); 60 | sorted_variants.sort_unstable(); 61 | log::warn!( 62 | "Ambiguous runtime mode, defaulting to {}. \ 63 | Please use one of the {} commands or set RSOP_MODE to either {}.", 64 | RsopMode::default().to_string(), 65 | BIN_NAME_TO_MODE 66 | .keys() 67 | .copied() 68 | .collect::>() 69 | .join("/"), 70 | sorted_variants.join("/") 71 | ); 72 | Ok(RsopMode::default()) 73 | } 74 | 75 | fn main() -> anyhow::Result<()> { 76 | // Init logger 77 | simple_logger::SimpleLogger::new() 78 | .init() 79 | .context("Failed to init logger")?; 80 | 81 | // Parse command line opts 82 | let mode = runtime_mode()?; 83 | log::trace!("Runtime mode: {:?}", mode); 84 | let cl_opts = cli::CommandLineOpts::parse(); 85 | log::trace!("{:?}", cl_opts); 86 | 87 | // Parse config 88 | let cfg = config::parse_config().context("Failed to read config")?; 89 | 90 | // Build mapping for fast searches 91 | let handlers = handler::HandlerMapping::new(&cfg).context("Failed to build handler mapping")?; 92 | log::debug!("{:?}", handlers); 93 | 94 | // Do the job 95 | if let Some(path) = cl_opts.path { 96 | handlers.handle_path(&mode, &path)?; 97 | } else { 98 | handlers.handle_pipe(&mode)?; 99 | } 100 | 101 | Ok(()) 102 | } 103 | --------------------------------------------------------------------------------