├── .github └── workflows │ ├── clippy.yml │ └── release.yml ├── .gitignore ├── CONTRIBUTING ├── Cargo.lock ├── Cargo.toml ├── README.md ├── UNLICENSE ├── scripts ├── kamp-buffers ├── kamp-fifo ├── kamp-files ├── kamp-filetypes ├── kamp-grep ├── kamp-lines ├── kamp-nnn └── kamp-sessions └── src ├── kamp.rs ├── kamp ├── argv.rs ├── cmd.rs ├── cmd │ ├── attach.rs │ ├── cat.rs │ ├── edit.rs │ ├── init.rs │ └── list.rs ├── context.rs ├── error.rs └── kak.rs └── main.rs /.github/workflows/clippy.yml: -------------------------------------------------------------------------------- 1 | name: clippy 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | tags-ignore: 8 | - v[0-9]+.* 9 | 10 | jobs: 11 | check: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | with: 16 | persist-credentials: false 17 | - run: rustup component add clippy 18 | - uses: actions-rs/clippy-check@v1 19 | with: 20 | token: ${{ secrets.GITHUB_TOKEN }} 21 | args: --all-features 22 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | push: 5 | tags: 6 | - v[0-9]+.* 7 | 8 | jobs: 9 | build: 10 | name: ${{ matrix.target }} 11 | strategy: 12 | fail-fast: false 13 | matrix: 14 | include: 15 | - target: aarch64-unknown-linux-gnu 16 | use-cross: true 17 | - target: aarch64-unknown-linux-musl 18 | use-cross: true 19 | - target: x86_64-unknown-linux-gnu 20 | - target: x86_64-unknown-linux-musl 21 | use-cross: true 22 | - target: x86_64-apple-darwin 23 | os: macos-latest 24 | - target: aarch64-apple-darwin 25 | os: macos-latest 26 | runs-on: ${{ matrix.os || 'ubuntu-latest' }} 27 | steps: 28 | - uses: actions/checkout@v4 29 | with: 30 | persist-credentials: false 31 | 32 | - uses: actions-rs/toolchain@v1 33 | with: 34 | toolchain: stable 35 | target: ${{ matrix.target }} 36 | override: true 37 | profile: minimal # minimal component installation (ie, no documentation) 38 | 39 | - name: Build 40 | uses: actions-rs/cargo@v1 41 | with: 42 | use-cross: ${{ matrix.use-cross }} 43 | command: build 44 | args: --locked --release --target=${{ matrix.target }} 45 | 46 | - name: Create tarball 47 | id: package 48 | shell: bash 49 | run: | 50 | PROJECT_BINARY=$(sed -n '/\[bin]/,$p' Cargo.toml | sed -n 's/^name = "\(.*\)"/\1/p' | head -n1) 51 | PROJECT_VERSION=$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -n1) 52 | PKG_STAGING=$(mktemp -d) 53 | PKG_BASENAME="${PROJECT_BINARY}-v${PROJECT_VERSION}-${{ matrix.target }}" 54 | PKG_NAME="${PKG_BASENAME}.tar.gz" 55 | 56 | ARCHIVE_DIR="$PKG_STAGING/$PKG_BASENAME/" 57 | mkdir -p "$ARCHIVE_DIR" 58 | 59 | # Binary 60 | cp "target/${{ matrix.target }}/release/$PROJECT_BINARY" "$ARCHIVE_DIR" 61 | 62 | # README, LICENSE and CHANGELOG files 63 | cp "README.md" "UNLICENSE" "$ARCHIVE_DIR" 64 | 65 | # include scripts dir 66 | tar -cf - scripts | tar -C "$ARCHIVE_DIR" -xf - 67 | 68 | # base compressed package 69 | tar -C "$ARCHIVE_DIR" -czf "$PKG_STAGING/$PKG_NAME" . 70 | 71 | # Let subsequent steps know where to find the compressed package 72 | echo "PKG_NAME=$PKG_NAME" | tee -a $GITHUB_ENV 73 | echo "PKG_PATH=$PKG_STAGING/$PKG_NAME" | tee -a $GITHUB_ENV 74 | 75 | - uses: actions/upload-artifact@v4 76 | with: 77 | name: ${{ env.PKG_NAME }} 78 | path: ${{ env.PKG_PATH }} 79 | 80 | - uses: softprops/action-gh-release@v2 81 | with: 82 | files: | 83 | ${{ env.PKG_PATH }} 84 | env: 85 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 86 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | -------------------------------------------------------------------------------- /CONTRIBUTING: -------------------------------------------------------------------------------- 1 | When contributing your first changes, please include an empty commit for 2 | copyright waiver using the following message (replace 'John Doe' with 3 | your name or nickname): 4 | 5 | John Doe Copyright Waiver 6 | 7 | I dedicate any and all copyright interest in this software to the 8 | public domain. I make this dedication for the benefit of the public at 9 | large and to the detriment of my heirs and successors. I intend this 10 | dedication to be an overt act of relinquishment in perpetuity of all 11 | present and future rights to this software under copyright law. 12 | 13 | The command to create an empty commit from the command-line is: 14 | 15 | git commit --allow-empty 16 | -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 4 4 | 5 | [[package]] 6 | name = "anyhow" 7 | version = "1.0.95" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "34ac096ce696dc2fcabef30516bb13c0a68a11d30131d3df6f04711467681b04" 10 | 11 | [[package]] 12 | name = "argh" 13 | version = "0.1.13" 14 | source = "registry+https://github.com/rust-lang/crates.io-index" 15 | checksum = "34ff18325c8a36b82f992e533ece1ec9f9a9db446bd1c14d4f936bac88fcd240" 16 | dependencies = [ 17 | "argh_derive", 18 | "argh_shared", 19 | "rust-fuzzy-search", 20 | ] 21 | 22 | [[package]] 23 | name = "argh_derive" 24 | version = "0.1.13" 25 | source = "registry+https://github.com/rust-lang/crates.io-index" 26 | checksum = "adb7b2b83a50d329d5d8ccc620f5c7064028828538bdf5646acd60dc1f767803" 27 | dependencies = [ 28 | "argh_shared", 29 | "proc-macro2", 30 | "quote", 31 | "syn", 32 | ] 33 | 34 | [[package]] 35 | name = "argh_shared" 36 | version = "0.1.13" 37 | source = "registry+https://github.com/rust-lang/crates.io-index" 38 | checksum = "a464143cc82dedcdc3928737445362466b7674b5db4e2eb8e869846d6d84f4f6" 39 | dependencies = [ 40 | "serde", 41 | ] 42 | 43 | [[package]] 44 | name = "kamp" 45 | version = "0.2.4-dev" 46 | dependencies = [ 47 | "anyhow", 48 | "argh", 49 | "thiserror", 50 | ] 51 | 52 | [[package]] 53 | name = "proc-macro2" 54 | version = "1.0.93" 55 | source = "registry+https://github.com/rust-lang/crates.io-index" 56 | checksum = "60946a68e5f9d28b0dc1c21bb8a97ee7d018a8b322fa57838ba31cc878e22d99" 57 | dependencies = [ 58 | "unicode-ident", 59 | ] 60 | 61 | [[package]] 62 | name = "quote" 63 | version = "1.0.38" 64 | source = "registry+https://github.com/rust-lang/crates.io-index" 65 | checksum = "0e4dccaaaf89514f546c693ddc140f729f958c247918a13380cccc6078391acc" 66 | dependencies = [ 67 | "proc-macro2", 68 | ] 69 | 70 | [[package]] 71 | name = "rust-fuzzy-search" 72 | version = "0.1.1" 73 | source = "registry+https://github.com/rust-lang/crates.io-index" 74 | checksum = "a157657054ffe556d8858504af8a672a054a6e0bd9e8ee531059100c0fa11bb2" 75 | 76 | [[package]] 77 | name = "serde" 78 | version = "1.0.217" 79 | source = "registry+https://github.com/rust-lang/crates.io-index" 80 | checksum = "02fc4265df13d6fa1d00ecff087228cc0a2b5f3c0e87e258d8b94a156e984c70" 81 | dependencies = [ 82 | "serde_derive", 83 | ] 84 | 85 | [[package]] 86 | name = "serde_derive" 87 | version = "1.0.217" 88 | source = "registry+https://github.com/rust-lang/crates.io-index" 89 | checksum = "5a9bf7cf98d04a2b28aead066b7496853d4779c9cc183c440dbac457641e19a0" 90 | dependencies = [ 91 | "proc-macro2", 92 | "quote", 93 | "syn", 94 | ] 95 | 96 | [[package]] 97 | name = "syn" 98 | version = "2.0.98" 99 | source = "registry+https://github.com/rust-lang/crates.io-index" 100 | checksum = "36147f1a48ae0ec2b5b3bc5b537d267457555a10dc06f3dbc8cb11ba3006d3b1" 101 | dependencies = [ 102 | "proc-macro2", 103 | "quote", 104 | "unicode-ident", 105 | ] 106 | 107 | [[package]] 108 | name = "thiserror" 109 | version = "2.0.11" 110 | source = "registry+https://github.com/rust-lang/crates.io-index" 111 | checksum = "d452f284b73e6d76dd36758a0c8684b1d5be31f92b89d07fd5822175732206fc" 112 | dependencies = [ 113 | "thiserror-impl", 114 | ] 115 | 116 | [[package]] 117 | name = "thiserror-impl" 118 | version = "2.0.11" 119 | source = "registry+https://github.com/rust-lang/crates.io-index" 120 | checksum = "26afc1baea8a989337eeb52b6e72a039780ce45c3edfcc9c5b9d112feeb173c2" 121 | dependencies = [ 122 | "proc-macro2", 123 | "quote", 124 | "syn", 125 | ] 126 | 127 | [[package]] 128 | name = "unicode-ident" 129 | version = "1.0.17" 130 | source = "registry+https://github.com/rust-lang/crates.io-index" 131 | checksum = "00e2473a93778eb0bad35909dff6a10d28e63f792f16ed15e404fca9d5eeedbe" 132 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "kamp" 3 | version = "0.2.4-dev" 4 | authors = ["Vladimir Bauer "] 5 | description = "kamp is a tool to control kakoune editor from the command line" 6 | repository = "https://github.com/vbauerster/kamp" 7 | keywords = ["kakoune"] 8 | categories = ["command-line-utilities"] 9 | license = "Unlicense" 10 | edition = "2021" 11 | 12 | [[bin]] 13 | path = "src/main.rs" 14 | name = "kamp" 15 | 16 | [dependencies] 17 | anyhow = "1.0.70" 18 | argh = "~0.1.9" 19 | thiserror = "2.0.3" 20 | 21 | [profile.release] 22 | lto = true 23 | strip = true 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Kamp 2 | 3 | Kamp is a tool to control [Kakoune](https://github.com/mawww/kakoune) editor from the command line. 4 | 5 | ## Installation 6 | 7 | ### From source 8 | 9 | Requires [Rust](https://www.rust-lang.org) installed on your system. 10 | 11 | Clone the repository and run `cargo install --path .` 12 | 13 | ### Nix 14 | 15 | With nix-env: 16 | 17 | ```shell-script 18 | nix-env -iA kamp 19 | ``` 20 | 21 | With modern nix command: 22 | 23 | ```shell-script 24 | nix profile install nixpkgs#kamp 25 | ``` 26 | 27 | ## Kakoune integration 28 | 29 | Add following definition into your kakrc. 30 | 31 | ```kak 32 | evaluate-commands %sh{ 33 | kamp init -a -e EDITOR='kamp edit' 34 | } 35 | ``` 36 | 37 | ## Provided scripts 38 | 39 | The [scripts](scripts) need to be added to `$PATH` in order to use them. 40 | 41 | | script | function | 42 | | ------------------------------------------ | -------------------------------- | 43 | | [`kamp-buffers`](scripts/kamp-buffers) | pick buffers (fzf) | 44 | | [`kamp-files`](scripts/kamp-files) | pick files (fzf) | 45 | | [`kamp-nnn`](scripts/kamp-nnn) | pick files (nnn) | 46 | | [`kamp-filetypes`](scripts/kamp-filetypes) | set filetype (fzf) | 47 | | [`kamp-lines`](scripts/kamp-lines) | search lines in buffer (fzf) | 48 | | [`kamp-sessions`](scripts/kamp-sessions) | attach session and pick a buffer | 49 | | [`kamp-grep`](scripts/kamp-grep) | grep interactively with fzf | 50 | | [`kamp-fifo`](scripts/kamp-fifo) | pipe stdin into fifo buffer | 51 | 52 | ### Kakoune mappings example 53 | 54 | Following mappings use [tmux-terminal-popup](https://github.com/alexherbo2/tmux.kak/blob/716d8a49be26b6c2332ad4f3c5342e485e02dff4/docs/manual.md#tmux-terminal-popup) as popup implementation. 55 | 56 | ```kak 57 | alias global popup tmux-terminal-popup 58 | map global normal -docstring 'files' ':connect popup kamp-files' 59 | map global normal -docstring 'git ls-files' ':connect popup kamp-files -b git' 60 | map global normal -docstring 'buffers' ':connect popup kamp-buffers' 61 | map global normal -docstring 'grep selection' ':connect popup kamp-grep -q ''%val{selection}''' 62 | map global normal -docstring 'grep by filetype' ':connect popup kamp-grep -- -t %opt{filetype}' 63 | ``` 64 | 65 | ## Shell integration 66 | 67 | You may want to set the `EDITOR` variable to `kamp edit` so that connected programs work as intended: 68 | 69 | ```sh 70 | export EDITOR='kamp edit' 71 | ``` 72 | 73 | Some useful aliases: 74 | 75 | ```sh 76 | alias k='kamp edit' 77 | alias kval='kamp get val' 78 | alias kopt='kamp get opt' 79 | alias kreg='kamp get reg' 80 | alias kcd-pwd='cd "$(kamp get sh pwd)"' 81 | alias kcd-buf='cd "$(dirname $(kamp get val buffile))"' 82 | alias kft='kamp get opt -b \* -s filetype | sort | uniq' # list file types you're working on 83 | ``` 84 | 85 | ## Similar projects 86 | 87 | - [kks](https://github.com/kkga/kks) 88 | - [kakoune.cr](https://github.com/alexherbo2/kakoune.cr) 89 | - [kakoune-remote-control](https://github.com/danr/kakoune-remote-control) 90 | -------------------------------------------------------------------------------- /UNLICENSE: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to 25 | -------------------------------------------------------------------------------- /scripts/kamp-buffers: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | # pick buffers 4 | # 5 | # requires: 6 | # - fzf (https://github.com/junegunn/fzf) 7 | # - bat (https://github.com/sharkdp/bat) 8 | 9 | set -euf 10 | 11 | usage() { 12 | printf "Usage: %s: [-s session]\n" "$(basename "$0")" >&2 13 | exit 2 14 | } 15 | 16 | while getopts 's:h' OPTION; do 17 | case $OPTION in 18 | s) 19 | session="$OPTARG" 20 | ;; 21 | h|?) 22 | usage 23 | ;; 24 | esac 25 | done 26 | shift $((OPTIND - 1)) 27 | 28 | : ${session:=$KAKOUNE_SESSION} 29 | 30 | if kamp ctx -c >/dev/null 2>/dev/null; then 31 | exec_cmd="kamp -s $session send buffer {}" 32 | else 33 | exec_cmd="kamp -s $session attach -b {}" 34 | fi 35 | 36 | buffers_cmd="kamp -s $session get -b \* val -s bufname" 37 | preview_cmd="kamp -s $session cat -b {} | bat --color=always --line-range=:500 --file-name {}" 38 | delete_cmd="kamp -s $session send -b {} delete-buffer" 39 | 40 | eval "$buffers_cmd" | 41 | fzf --prompt 'buf> ' --preview "$preview_cmd" \ 42 | --header '[c-x] delete' \ 43 | --bind "ctrl-x:execute-silent($delete_cmd)+reload($buffers_cmd)" \ 44 | --bind "enter:become($exec_cmd)" 45 | -------------------------------------------------------------------------------- /scripts/kamp-fifo: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | # pipe stdin into fifo buffer 4 | # 5 | # Example: make | kamp-fifo 6 | 7 | set -euf 8 | 9 | usage() { 10 | printf "Usage: %s: [-c client] [fifo_buffer_name]\n" "$(basename "$0")" >&2 11 | exit 2 12 | } 13 | 14 | cflag= 15 | while getopts 'c:h' OPTION; do 16 | case $OPTION in 17 | c) 18 | cflag=1 19 | cval="$OPTARG" 20 | ;; 21 | h|?) 22 | usage 23 | ;; 24 | esac 25 | done 26 | shift $((OPTIND - 1)) 27 | 28 | kamp >/dev/null # fail early if there is no session 29 | 30 | d=$(mktemp -d) 31 | fifo="$d/fifo" 32 | mkfifo "$fifo" 33 | 34 | trap 'unlink "$fifo" && rmdir "$d"; exit' EXIT HUP INT TERM 35 | 36 | if [ "$cflag" ]; then 37 | kamp -c "$cval" send edit -scroll -fifo "$fifo" "*${1:-kamp-fifo}*" \; focus 38 | else 39 | kamp ctx -c >/dev/null # fail early if there is no client 40 | kamp send edit -scroll -fifo "$fifo" "*${1:-kamp-fifo}*" \; focus 41 | fi 42 | 43 | cat >"$fifo" 44 | -------------------------------------------------------------------------------- /scripts/kamp-files: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | # pick files 4 | # 5 | # requires: 6 | # - fd (https://github.com/sharkdp/fd) 7 | # - fzf (https://github.com/junegunn/fzf) 8 | # - bat (https://github.com/sharkdp/bat) 9 | 10 | set -euf 11 | 12 | usage() { 13 | printf "Usage: %s: [-b ] -- [backend_options]\n" "$(basename "$0")" >&2 14 | exit 2 15 | } 16 | 17 | backend="fd" 18 | while getopts 'b:h' OPTION; do 19 | case $OPTION in 20 | b) 21 | backend="$OPTARG" 22 | ;; 23 | h|?) 24 | usage 25 | ;; 26 | esac 27 | done 28 | shift $((OPTIND - 1)) 29 | 30 | case "$backend" in 31 | fd) 32 | backend_cmd="fd --strip-cwd-prefix --type file $*" 33 | ;; 34 | rg) 35 | backend_cmd="rg --files $*" 36 | ;; 37 | find) 38 | backend_cmd="find . -type f $*" 39 | ;; 40 | git) 41 | backend_cmd="git ls-files $*" 42 | ;; 43 | *) 44 | usage 45 | ;; 46 | esac 47 | 48 | preview_cmd='bat --color=always --line-range=:500 {}' 49 | 50 | eval "$backend_cmd" | 51 | fzf --multi --prompt "${backend}> " --preview "$preview_cmd" --bind 'enter:become(kamp edit {+})' 52 | -------------------------------------------------------------------------------- /scripts/kamp-filetypes: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | # pick filetype from Kakoune's runtime dir and set in current buffer 4 | # 5 | # requires: 6 | # - fzf (https://github.com/junegunn/fzf) 7 | 8 | ft_dir="$(kamp get val runtime)/rc/filetype" 9 | 10 | find "$ft_dir"/*.kak -type f -exec basename -s .kak {} \; | 11 | fzf --no-preview --prompt 'filetypes> ' | 12 | xargs -I {} kamp send set buffer filetype {} 13 | -------------------------------------------------------------------------------- /scripts/kamp-grep: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | # Open files by content. 4 | # 5 | # requires: 6 | # – fzf (https://github.com/junegunn/fzf) 7 | # https://github.com/junegunn/fzf/blob/master/CHANGELOG.md#0190 8 | # – ripgrep (https://github.com/BurntSushi/ripgrep) 9 | # 'rg --no-heading --with-filename' are defaults when not printing to a terminal 10 | # 'rg --column' implies --line-number 11 | # - bat (https://github.com/sharkdp/bat) 12 | 13 | set -euf 14 | 15 | # define SHELL so --preview arguments do not error if current SHELL is not POSIX 16 | SHELL=/bin/sh 17 | 18 | usage() { 19 | printf "Usage: %s: [-q query] -- [rg_options]\n" "$(basename "$0")" >&2 20 | exit 2 21 | } 22 | 23 | qflag= 24 | while getopts 'q:h' OPTION; do 25 | case $OPTION in 26 | q) 27 | qflag=1 28 | query="$OPTARG" 29 | ;; 30 | h|?) 31 | usage 32 | ;; 33 | esac 34 | done 35 | shift $((OPTIND - 1)) 36 | 37 | rg_cmd="rg --color always --column $*" 38 | 39 | if [ ! "$qflag" ]; then 40 | query="$(kamp get opt kamp_grep_query)" 41 | fi 42 | 43 | if [ -z "$query" ]; then 44 | export FZF_DEFAULT_COMMAND="rg --files $*" 45 | else 46 | pattern=$(printf %s "$query" | sed 's/"/\\"/') 47 | export FZF_DEFAULT_COMMAND="$rg_cmd -- \"$pattern\"" 48 | fi 49 | 50 | fzf \ 51 | --disabled \ 52 | --query "$query" \ 53 | --delimiter ':' \ 54 | --ansi \ 55 | --bind "change:reload($rg_cmd -- {q} || true)" \ 56 | --bind 'enter:execute-silent(kamp send set global kamp_grep_query {q})+become(kamp edit {1} +{2}:{3})' \ 57 | --preview ' 58 | highlight_line={2} 59 | line_range_begin=$((highlight_line - FZF_PREVIEW_LINES / 2)) 60 | bat \ 61 | --terminal-width $FZF_PREVIEW_COLUMNS \ 62 | --style=numbers \ 63 | --color=always \ 64 | --line-range "$((line_range_begin < 0 ? 1 : line_range_begin)):+$FZF_PREVIEW_LINES" \ 65 | --highlight-line {2} {1} 2> /dev/null' \ 66 | --header 'type to grep' \ 67 | --prompt 'grep> ' 68 | -------------------------------------------------------------------------------- /scripts/kamp-lines: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | # jump to line in buffer 4 | # 5 | # requires: 6 | # - fzf (https://github.com/junegunn/fzf) 7 | 8 | kamp cat | 9 | nl -ba -s' │ ' | 10 | fzf --no-preview --prompt 'lines> ' | 11 | awk '{print $1}' | 12 | xargs -r -I {} kamp send execute-keys '{}g' 13 | -------------------------------------------------------------------------------- /scripts/kamp-nnn: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | # pick files with nnn 4 | # 5 | # requires: 6 | # - nnn (https://github.com/jarun/nnn) 7 | # 8 | # example mapping: 9 | # map global user n ':terminal-popup kamp-nnn %val{buffile}' -docstring 'pick with nnn' 10 | 11 | set -ef 12 | 13 | nnn -e -p - "$1" | xargs -r kamp edit 14 | -------------------------------------------------------------------------------- /scripts/kamp-sessions: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | # attach session and pick a buffer 4 | # 5 | # requires: 6 | # - fzf (https://github.com/junegunn/fzf) 7 | # - kamp-buffers 8 | 9 | set -euf 10 | 11 | sessions_cmd='kak -l' 12 | preview_cmd='kamp -s {} list' 13 | kill_cmd='kamp -s {} kill' 14 | 15 | eval "$sessions_cmd" | 16 | fzf --prompt 'session> ' --preview "$preview_cmd" \ 17 | --header '[c-x] kill' \ 18 | --bind "ctrl-x:execute-silent($kill_cmd)+reload($sessions_cmd)" \ 19 | --bind 'enter:become(kamp-buffers -s {})' 20 | -------------------------------------------------------------------------------- /src/kamp.rs: -------------------------------------------------------------------------------- 1 | mod argv; 2 | mod cmd; 3 | mod context; 4 | mod error; 5 | mod kak; 6 | 7 | use argv::SubCommand; 8 | use context::Context; 9 | use error::{Error, Result}; 10 | use std::io::Write; 11 | 12 | const KAKOUNE_SESSION: &str = "KAKOUNE_SESSION"; 13 | const KAKOUNE_CLIENT: &str = "KAKOUNE_CLIENT"; 14 | 15 | pub(crate) trait Dispatcher { 16 | fn dispatch(self, ctx: Context, writer: W) -> Result<()>; 17 | } 18 | 19 | pub(super) fn run() -> Result<()> { 20 | let kamp: argv::Kampliment = argh::from_env(); 21 | if kamp.version { 22 | println!("{} {}", env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION")); 23 | return Ok(()); 24 | } 25 | 26 | let session = kamp 27 | .session 28 | .filter(|s| !s.is_empty()) 29 | .or_else(|| std::env::var(KAKOUNE_SESSION).ok()); 30 | 31 | let client = match session { 32 | None => None, 33 | Some(_) => kamp 34 | .client 35 | .filter(|s| !s.is_empty()) 36 | .or_else(|| std::env::var(KAKOUNE_CLIENT).ok()), 37 | }; 38 | 39 | let command = kamp 40 | .subcommand 41 | .unwrap_or_else(|| SubCommand::Ctx(Default::default())); 42 | 43 | let mut output = std::io::stdout(); 44 | 45 | match command { 46 | SubCommand::Init(opt) => cmd::init(opt.export, opt.alias) 47 | .and_then(|res| write!(output, "{res}").map_err(From::from)), 48 | SubCommand::Ctx(opt) => { 49 | let Some(session) = session else { 50 | return Err(Error::InvalidContext("session is required")); 51 | }; 52 | match (opt.client, client) { 53 | (true, None) => Err(Error::InvalidContext("client is required")), 54 | (true, Some(client)) => writeln!(output, "client: {client}").map_err(From::from), 55 | (false, _) => writeln!(output, "session: {session}").map_err(From::from), 56 | } 57 | } 58 | SubCommand::List(opt) if opt.all => { 59 | let sessions = kak::list_sessions()?; 60 | let sessions = String::from_utf8(sessions).map_err(anyhow::Error::new)?; 61 | cmd::list_all(sessions.lines()).and_then(|v| { 62 | v.into_iter() 63 | .try_for_each(|session| writeln!(output, "{session:#?}")) 64 | .map_err(From::from) 65 | }) 66 | } 67 | SubCommand::Edit(opt) if session.is_none() => kak::proxy(opt.files).map_err(From::from), 68 | _ => session 69 | .ok_or_else(|| Error::InvalidContext("session is required")) 70 | .map(|s| Context::new(Box::leak(s.into_boxed_str()), client)) 71 | .and_then(|ctx| ctx.dispatch(command, output)), 72 | } 73 | } 74 | 75 | impl Dispatcher for SubCommand { 76 | fn dispatch(self, ctx: Context, mut writer: W) -> Result<()> { 77 | match self { 78 | SubCommand::Attach(opt) => cmd::attach(ctx, opt.buffer), 79 | SubCommand::Edit(opt) => { 80 | let session = ctx.session(); 81 | let client = ctx.client(); 82 | let scratch = cmd::edit(ctx, opt.focus, opt.files)?; 83 | if let (Some(client), false) = (client, opt.focus) { 84 | writeln!( 85 | writer, 86 | "{} is opened in client: {client}, session: {session}", 87 | if scratch { "scratch" } else { "file" } 88 | )?; 89 | } 90 | Ok(()) 91 | } 92 | SubCommand::Send(opt) => { 93 | if opt.command.is_empty() { 94 | return Err(Error::CommandRequired); 95 | } 96 | let body = if opt.verbatim { 97 | opt.command.join(" ") 98 | } else { 99 | opt.command 100 | .into_iter() 101 | .fold(String::new(), |mut buf, next| { 102 | if !buf.is_empty() { 103 | buf.push(' '); 104 | } 105 | let s = next.replace("'", "''"); 106 | if s.is_empty() || s.contains(' ') { 107 | buf.push('\''); 108 | buf.push_str(&s); 109 | buf.push('\''); 110 | } else { 111 | buf.push_str(&s); 112 | } 113 | buf 114 | }) 115 | }; 116 | ctx.send(to_buffer_ctx(opt.buffers), body).map(drop) 117 | } 118 | SubCommand::List(_) => cmd::list_current(ctx) 119 | .and_then(|session| writeln!(writer, "{session:#?}").map_err(From::from)), 120 | SubCommand::Kill(opt) => ctx.send_kill(opt.exit_status), 121 | SubCommand::Get(opt) => { 122 | use argv::get::SubCommand; 123 | let buffer_ctx = to_buffer_ctx(opt.buffers); 124 | let res = match opt.subcommand { 125 | SubCommand::Value(o) => ctx 126 | .query_val(buffer_ctx, o.name, o.quote, o.split || o.zplit) 127 | .map(|v| (v, !o.quote && o.zplit)), 128 | SubCommand::Option(o) => ctx 129 | .query_opt(buffer_ctx, o.name, o.quote, o.split || o.zplit) 130 | .map(|v| (v, !o.quote && o.zplit)), 131 | SubCommand::Register(o) => ctx 132 | .query_reg(buffer_ctx, o.name, o.quote, o.split || o.zplit) 133 | .map(|v| (v, !o.quote && o.zplit)), 134 | SubCommand::Shell(o) => { 135 | if o.command.is_empty() { 136 | return Err(Error::CommandRequired); 137 | } 138 | ctx.query_sh(buffer_ctx, o.command.join(" ")) 139 | .map(|v| (v, false)) 140 | } 141 | }; 142 | res.and_then(|(items, zplit)| { 143 | let split_char = if zplit { '\0' } else { '\n' }; 144 | items 145 | .into_iter() 146 | .try_for_each(|item| write!(writer, "{item}{split_char}")) 147 | .map_err(From::from) 148 | }) 149 | } 150 | SubCommand::Cat(opt) => cmd::cat(ctx, to_buffer_ctx(opt.buffers)) 151 | .and_then(|res| write!(writer, "{res}").map_err(From::from)), 152 | _ => unreachable!(), 153 | } 154 | } 155 | } 156 | 157 | fn to_buffer_ctx(buffers: Vec) -> Option<(String, i32)> { 158 | let mut iter = buffers.into_iter(); 159 | let first = iter.next()?; 160 | let mut res = String::from('\''); 161 | res.push_str(&first); 162 | if first == "*" { 163 | res.push('\''); 164 | return Some((res, 0)); 165 | } 166 | 167 | let mut count = 1; 168 | let mut res = iter.filter(|s| s != "*").fold(res, |mut buf, next| { 169 | buf.push(','); 170 | buf.push_str(&next); 171 | count += 1; 172 | buf 173 | }); 174 | res.push('\''); 175 | Some((res, count)) 176 | } 177 | 178 | #[cfg(test)] 179 | mod tests { 180 | use super::*; 181 | #[test] 182 | fn test_to_buffer_ctx() { 183 | assert_eq!(to_buffer_ctx(vec![]), None); 184 | assert_eq!(to_buffer_ctx(vec!["*".into()]), Some(("'*'".into(), 0))); 185 | assert_eq!( 186 | to_buffer_ctx(vec!["*".into(), "a".into()]), 187 | Some(("'*'".into(), 0)) 188 | ); 189 | assert_eq!( 190 | to_buffer_ctx(vec!["a".into(), "*".into()]), 191 | Some(("'a'".into(), 1)) 192 | ); 193 | assert_eq!( 194 | to_buffer_ctx(vec!["a".into(), "*".into(), "b".into()]), 195 | Some(("'a,b'".into(), 2)) 196 | ); 197 | assert_eq!(to_buffer_ctx(vec!["a".into()]), Some(("'a'".into(), 1))); 198 | assert_eq!( 199 | to_buffer_ctx(vec!["a".into(), "b".into()]), 200 | Some(("'a,b'".into(), 2)) 201 | ); 202 | } 203 | } 204 | -------------------------------------------------------------------------------- /src/kamp/argv.rs: -------------------------------------------------------------------------------- 1 | use argh::{FromArgValue, FromArgs}; 2 | 3 | /// Kakoune kampliment 4 | #[derive(FromArgs, PartialEq, Debug)] 5 | pub(super) struct Kampliment { 6 | /// session 7 | #[argh(option, short = 's')] 8 | pub session: Option, 9 | 10 | /// client 11 | #[argh(option, short = 'c')] 12 | pub client: Option, 13 | 14 | /// display version and exit 15 | #[argh(switch, short = 'v')] 16 | pub version: bool, 17 | 18 | #[argh(subcommand)] 19 | pub subcommand: Option, 20 | } 21 | 22 | #[derive(FromArgs, PartialEq, Debug)] 23 | #[argh(subcommand)] 24 | pub(super) enum SubCommand { 25 | Init(init::Options), 26 | Attach(attach::Options), 27 | Edit(edit::Options), 28 | Send(send::Options), 29 | Kill(kill::Options), 30 | List(list::Options), 31 | Get(get::Options), 32 | Cat(cat::Options), 33 | Ctx(ctx::Options), 34 | } 35 | 36 | pub(super) mod init { 37 | use super::*; 38 | /// kakoune init 39 | #[derive(FromArgs, PartialEq, Debug)] 40 | #[argh(subcommand, name = "init")] 41 | pub struct Options { 42 | /// alias global connect kamp-connect 43 | #[argh(switch, short = 'a')] 44 | pub alias: bool, 45 | 46 | /// inject 'export VAR=VALUE' into the kamp-connect 47 | #[argh(option, short = 'e')] 48 | pub export: Vec, 49 | } 50 | 51 | #[derive(PartialEq, Eq, Debug)] 52 | pub struct KeyValue { 53 | pub key: String, 54 | pub value: String, 55 | } 56 | 57 | impl FromArgValue for KeyValue { 58 | fn from_arg_value(value: &str) -> Result { 59 | value 60 | .split_once('=') 61 | .map(|(key, value)| KeyValue { 62 | key: key.trim().into(), 63 | value: value.trim_matches(|c| c == '\'' || c == '"').into(), 64 | }) 65 | .ok_or_else(|| "invalid KEY=VALUE pair".into()) 66 | } 67 | } 68 | } 69 | 70 | mod attach { 71 | use super::*; 72 | /// attach to a session in context 73 | #[derive(FromArgs, PartialEq, Debug)] 74 | #[argh(subcommand, name = "attach")] 75 | pub struct Options { 76 | /// switch to buffer 77 | #[argh(option, short = 'b')] 78 | pub buffer: Option, 79 | } 80 | } 81 | 82 | mod edit { 83 | use super::*; 84 | /// edit a file 85 | #[derive(FromArgs, PartialEq, Debug)] 86 | #[argh(subcommand, name = "edit")] 87 | pub struct Options { 88 | /// focus client in context 89 | #[argh(switch, short = 'f')] 90 | pub focus: bool, 91 | 92 | /// path to file 93 | #[argh(positional, arg_name = "file")] 94 | pub files: Vec, 95 | } 96 | } 97 | 98 | mod send { 99 | use super::*; 100 | /// send command to a session in context 101 | #[derive(FromArgs, PartialEq, Debug)] 102 | #[argh(subcommand, name = "send")] 103 | pub struct Options { 104 | /// do not parse/escape command 105 | #[argh(switch, short = 'v')] 106 | pub verbatim: bool, 107 | 108 | /// buffer context or '*' for all non-debug buffers 109 | #[argh(option, short = 'b', long = "buffer", arg_name = "buffer")] 110 | pub buffers: Vec, 111 | 112 | /// command to send 113 | #[argh(positional, greedy)] 114 | pub command: Vec, 115 | } 116 | } 117 | 118 | mod kill { 119 | use super::*; 120 | /// kill session 121 | #[derive(FromArgs, PartialEq, Debug)] 122 | #[argh(subcommand, name = "kill")] 123 | pub struct Options { 124 | /// exit status 125 | #[argh(positional)] 126 | pub exit_status: Option, 127 | } 128 | } 129 | 130 | mod list { 131 | use super::*; 132 | /// list session(s) 133 | #[derive(FromArgs, PartialEq, Debug)] 134 | #[argh(subcommand, name = "list")] 135 | pub struct Options { 136 | /// all sessions 137 | #[argh(switch, short = 'a')] 138 | pub all: bool, 139 | } 140 | } 141 | 142 | pub(super) mod get { 143 | use super::*; 144 | /// get state from a session in context 145 | #[derive(FromArgs, PartialEq, Debug)] 146 | #[argh(subcommand, name = "get")] 147 | pub struct Options { 148 | /// buffer context or '*' for all non-debug buffers 149 | #[argh(option, short = 'b', long = "buffer", arg_name = "buffer")] 150 | pub buffers: Vec, 151 | 152 | #[argh(subcommand)] 153 | pub subcommand: SubCommand, 154 | } 155 | 156 | #[derive(FromArgs, PartialEq, Debug)] 157 | #[argh(subcommand)] 158 | pub enum SubCommand { 159 | Value(value::Options), 160 | Option(option::Options), 161 | Register(register::Options), 162 | Shell(shell::Options), 163 | } 164 | 165 | mod value { 166 | use super::*; 167 | /// get value as %val(name) 168 | #[derive(FromArgs, PartialEq, Debug)] 169 | #[argh(subcommand, name = "val")] 170 | pub struct Options { 171 | /// quoting style kakoune, discards any --split 172 | #[argh(switch, short = 'q')] 173 | pub quote: bool, 174 | 175 | /// split by new line, for example 'buflist' value 176 | #[argh(switch, short = 's')] 177 | pub split: bool, 178 | 179 | /// split by null character 180 | #[argh(switch, short = 'z')] 181 | pub zplit: bool, 182 | 183 | /// value name to query (required) 184 | #[argh(positional)] 185 | pub name: String, 186 | } 187 | } 188 | 189 | mod option { 190 | use super::*; 191 | /// get option as %opt(name) 192 | #[derive(FromArgs, PartialEq, Debug)] 193 | #[argh(subcommand, name = "opt")] 194 | pub struct Options { 195 | /// quoting style kakoune, discards any --split 196 | #[argh(switch, short = 'q')] 197 | pub quote: bool, 198 | 199 | /// split by new line, for example 'str-list' type option 200 | #[argh(switch, short = 's')] 201 | pub split: bool, 202 | 203 | /// split by null character 204 | #[argh(switch, short = 'z')] 205 | pub zplit: bool, 206 | 207 | /// option name to query (required) 208 | #[argh(positional)] 209 | pub name: String, 210 | } 211 | } 212 | 213 | mod register { 214 | use super::*; 215 | /// get register as %reg(name) 216 | #[derive(FromArgs, PartialEq, Debug)] 217 | #[argh(subcommand, name = "reg")] 218 | pub struct Options { 219 | /// quoting style kakoune, discards any --split 220 | #[argh(switch, short = 'q')] 221 | pub quote: bool, 222 | 223 | /// split by new line, for example ':' register 224 | #[argh(switch, short = 's')] 225 | pub split: bool, 226 | 227 | /// split by null character 228 | #[argh(switch, short = 'z')] 229 | pub zplit: bool, 230 | 231 | /// register name to query, " is default 232 | #[argh(positional, default = r#"String::from("dquote")"#)] 233 | pub name: String, 234 | } 235 | } 236 | 237 | mod shell { 238 | use super::*; 239 | /// evaluate shell command as %sh(command) 240 | #[derive(FromArgs, PartialEq, Eq, Debug)] 241 | #[argh(subcommand, name = "sh")] 242 | pub struct Options { 243 | /// shell command to evaluate 244 | #[argh(positional, greedy)] 245 | pub command: Vec, 246 | } 247 | } 248 | } 249 | 250 | mod cat { 251 | use super::*; 252 | /// print buffer content 253 | #[derive(FromArgs, PartialEq, Debug)] 254 | #[argh(subcommand, name = "cat")] 255 | pub struct Options { 256 | /// buffer context or '*' for all non-debug buffers 257 | #[argh(option, short = 'b', long = "buffer", arg_name = "buffer")] 258 | pub buffers: Vec, 259 | } 260 | } 261 | 262 | mod ctx { 263 | use super::*; 264 | /// print session context (default) 265 | #[derive(FromArgs, PartialEq, Debug, Default)] 266 | #[argh(subcommand, name = "ctx")] 267 | pub struct Options { 268 | /// print client if any otherwise throw an error 269 | #[argh(switch, short = 'c')] 270 | pub client: bool, 271 | } 272 | } 273 | -------------------------------------------------------------------------------- /src/kamp/cmd.rs: -------------------------------------------------------------------------------- 1 | mod attach; 2 | mod cat; 3 | mod edit; 4 | mod init; 5 | mod list; 6 | 7 | use super::context::*; 8 | use super::{Error, Result}; 9 | 10 | pub(super) use attach::attach; 11 | pub(super) use cat::cat; 12 | pub(super) use edit::edit; 13 | pub(super) use init::init; 14 | pub(super) use list::*; 15 | -------------------------------------------------------------------------------- /src/kamp/cmd/attach.rs: -------------------------------------------------------------------------------- 1 | use super::Context; 2 | use super::Result; 3 | 4 | pub(crate) fn attach(ctx: Context, buffer: Option) -> Result<()> { 5 | let mut cmd = String::new(); 6 | if let Some(buffer) = &buffer { 7 | cmd.push_str(r#"eval %sh{ case "$kak_bufname" in (\*stdin*) echo delete-buffer ;; esac }"#); 8 | cmd.push_str("\nbuffer '"); 9 | cmd.push_str(buffer); 10 | cmd.push('\''); 11 | } 12 | ctx.connect(&cmd) 13 | } 14 | -------------------------------------------------------------------------------- /src/kamp/cmd/cat.rs: -------------------------------------------------------------------------------- 1 | use super::Context; 2 | use super::{Error, Result}; 3 | 4 | pub(crate) fn cat(ctx: Context, buffer_ctx: Option<(String, i32)>) -> Result { 5 | if ctx.is_draft() && buffer_ctx.is_none() { 6 | return Err(Error::InvalidContext("either client or buffer is required")); 7 | } 8 | ctx.send(buffer_ctx, "write %opt{kamp_out}") 9 | } 10 | -------------------------------------------------------------------------------- /src/kamp/cmd/edit.rs: -------------------------------------------------------------------------------- 1 | use std::path::Path; 2 | use std::path::PathBuf; 3 | 4 | use super::{Context, Error, Result}; 5 | 6 | pub(crate) fn edit(ctx: Context, focus: bool, files: Vec) -> Result { 7 | if focus && ctx.is_draft() { 8 | return Err(anyhow::Error::msg("focus needs some client in context").into()); 9 | } 10 | let mut buf = String::new(); 11 | let mut pair = [None; 2]; 12 | let mut coord = None; 13 | let mut iter = files.iter(); 14 | 15 | for (i, item) in iter.by_ref().take(2).enumerate() { 16 | if Path::new(item).exists() || !item.starts_with('+') { 17 | pair[1 - i] = Some(item); 18 | continue; 19 | } 20 | if coord.is_none() { 21 | coord = Some(parse(item)?); 22 | } else { 23 | return Err(Error::InvalidCoordinates { 24 | coord: item.clone(), 25 | source: anyhow::Error::msg("invalid position"), 26 | }); 27 | } 28 | } 29 | 30 | for (i, file) in iter.rev().chain(pair.into_iter().flatten()).enumerate() { 31 | let p = std::fs::canonicalize(file).unwrap_or_else(|_| PathBuf::from(file)); 32 | if let Some(p) = p.as_path().to_str() { 33 | if i != 0 { 34 | buf.push('\n'); 35 | } 36 | buf.push_str("edit -existing '"); 37 | if p.contains('\'') { 38 | buf.push_str(&p.replace('\'', "''")); 39 | } else { 40 | buf.push_str(p); 41 | } 42 | buf.push('\''); 43 | } 44 | } 45 | 46 | if let Some(v) = coord { 47 | for item in v { 48 | buf.push(' '); 49 | buf.push_str(&item.to_string()); 50 | } 51 | } 52 | 53 | let is_scratch = buf.is_empty(); 54 | if is_scratch { 55 | buf.push_str("edit -scratch"); 56 | } 57 | 58 | if !ctx.is_draft() { 59 | if focus { 60 | buf.push_str("\nfocus"); 61 | } 62 | ctx.send(None, &buf).map(|_| is_scratch) 63 | } else { 64 | // this one acts like attach 65 | ctx.connect(&buf).map(|_| is_scratch) 66 | } 67 | } 68 | 69 | // assuming coord starts with '+' 70 | fn parse(coord: &str) -> Result> { 71 | // parsing first value as '+n' so '+:' will fail 72 | coord 73 | .splitn(2, ':') 74 | .take_while(|&s| !s.is_empty()) // make sure '+n:' is valid 75 | .map(|s| { 76 | s.parse().map_err(|e| Error::InvalidCoordinates { 77 | coord: String::from(coord), 78 | source: anyhow::Error::new(e), 79 | }) 80 | }) 81 | .collect() 82 | } 83 | 84 | #[cfg(test)] 85 | mod tests { 86 | use super::*; 87 | #[test] 88 | fn test_parse_ok() -> Result<()> { 89 | assert_eq!(parse("+1")?, vec![1]); 90 | assert_eq!(parse("+1:")?, vec![1]); 91 | assert_eq!(parse("+1:1")?, vec![1, 1]); 92 | Ok(()) 93 | } 94 | #[test] 95 | fn test_parse_err() { 96 | assert!(parse("+").is_err()); 97 | assert!(parse("+:").is_err()); 98 | assert!(parse("+:+").is_err()); 99 | assert!(parse("+:1").is_err()); 100 | assert!(parse("++").is_err()); 101 | assert!(parse("++:").is_err()); 102 | assert!(parse("++:1").is_err()); 103 | assert!(parse("++1:").is_err()); 104 | assert!(parse("++1:1").is_err()); 105 | assert!(parse("+a").is_err()); 106 | assert!(parse("+a:").is_err()); 107 | assert!(parse("+a:1").is_err()); 108 | assert!(parse("+1:a").is_err()); 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/kamp/cmd/init.rs: -------------------------------------------------------------------------------- 1 | use super::Result; 2 | use crate::kamp::argv::init::KeyValue; 3 | use std::fmt::Write; 4 | 5 | const KAKOUNE_INIT: &str = r#" 6 | define-command -hidden -override kamp-init %{ 7 | declare-option -hidden str kamp_grep_query 8 | declare-option -hidden str kamp_out 9 | declare-option -hidden str kamp_err 10 | evaluate-commands %sh{ 11 | kamp_out="${TMPDIR:-/tmp}/kamp-${kak_session}.out" 12 | kamp_err="${TMPDIR:-/tmp}/kamp-${kak_session}.err" 13 | mkfifo "$kamp_out" "$kamp_err" 14 | printf 'set global kamp_%s %s\n' out "$kamp_out" err "$kamp_err" 15 | } 16 | } 17 | 18 | define-command -hidden -override kamp-end %{ 19 | nop %sh{ rm -f "$kak_opt_kamp_out" "$kak_opt_kamp_err" } 20 | } 21 | 22 | hook global KakBegin .* kamp-init 23 | hook global KakEnd .* kamp-end 24 | "#; 25 | 26 | pub(crate) fn init(export: Vec, alias: bool) -> Result { 27 | let user_exports = export.into_iter().fold(String::new(), |mut buf, next| { 28 | buf.push_str("export "); 29 | buf.push_str(&next.key); 30 | buf.push_str("=\""); 31 | buf.push_str(&next.value); 32 | buf.push_str("\"\n"); 33 | (0..8).for_each(|_| buf.push(' ')); 34 | buf 35 | }); 36 | 37 | let mut buf = String::new(); 38 | 39 | writeln!( 40 | &mut buf, 41 | r#" 42 | define-command -override kamp-connect -params 1.. -command-completion %{{ 43 | %arg{{1}} sh -c %{{ 44 | {user_exports}export KAKOUNE_SESSION="$1" 45 | export KAKOUNE_CLIENT="$2" 46 | shift 3 47 | 48 | [ $# -eq 0 ] && set "$SHELL" 49 | 50 | "$@" 51 | }} -- %val{{session}} %val{{client}} %arg{{@}} 52 | }} -docstring 'run Kakoune command in connected context'"# 53 | )?; 54 | 55 | buf.push_str(KAKOUNE_INIT); 56 | 57 | if alias { 58 | buf.push_str("alias global connect kamp-connect\n"); 59 | } 60 | 61 | Ok(buf) 62 | } 63 | -------------------------------------------------------------------------------- /src/kamp/cmd/list.rs: -------------------------------------------------------------------------------- 1 | use std::rc::Rc; 2 | 3 | use super::Context; 4 | use super::Result; 5 | 6 | #[allow(unused)] 7 | #[derive(Debug)] 8 | pub struct Session<'a> { 9 | name: &'a str, 10 | pwd: String, 11 | clients: Vec, 12 | } 13 | 14 | impl<'a> Session<'a> { 15 | fn new(name: &'a str, pwd: String, clients: Vec) -> Self { 16 | Session { name, pwd, clients } 17 | } 18 | } 19 | 20 | #[allow(unused)] 21 | #[derive(Debug)] 22 | pub struct Client { 23 | name: Rc, 24 | bufname: String, 25 | } 26 | 27 | impl Client { 28 | fn new(name: Rc, bufname: String) -> Client { 29 | Client { name, bufname } 30 | } 31 | } 32 | 33 | pub(crate) fn list_all<'a>(sessions: impl Iterator) -> Result>> { 34 | sessions.map(|s| list_current(Context::from(s))).collect() 35 | } 36 | 37 | pub(crate) fn list_current(mut ctx: Context) -> Result> { 38 | let clients = ctx.query_val(None, "client_list", false, true)?; 39 | let clients = clients 40 | .into_iter() 41 | .flat_map(|name| { 42 | ctx.set_client(name); 43 | ctx.query_val(None, "bufname", false, false) 44 | .map(|mut v| Client::new(ctx.client().unwrap(), v.pop().unwrap_or_default())) 45 | }) 46 | .collect(); 47 | ctx.unset_client(); 48 | ctx.query_sh(None, "pwd") 49 | .map(|mut pwd| Session::new(ctx.session(), pwd.pop().unwrap_or_default(), clients)) 50 | } 51 | -------------------------------------------------------------------------------- /src/kamp/context.rs: -------------------------------------------------------------------------------- 1 | use super::kak; 2 | use super::{Error, Result}; 3 | use std::borrow::Cow; 4 | use std::io::prelude::*; 5 | use std::path::Path; 6 | use std::rc::Rc; 7 | use std::sync::mpsc::{sync_channel, SyncSender}; 8 | use std::sync::Arc; 9 | use std::thread; 10 | 11 | const END_TOKEN: &str = "<>"; 12 | 13 | enum QuotingStyle { 14 | Raw, 15 | Kakoune, 16 | } 17 | 18 | enum ParseType { 19 | None(QuotingStyle), 20 | Kakoune, 21 | } 22 | 23 | impl ParseType { 24 | fn new(quote: bool, split: bool) -> Self { 25 | match (quote, split) { 26 | (true, _) => ParseType::None(QuotingStyle::Kakoune), 27 | (_, false) => ParseType::None(QuotingStyle::Raw), 28 | _ => ParseType::Kakoune, 29 | } 30 | } 31 | fn quoting(&self) -> &'static str { 32 | match self { 33 | ParseType::None(QuotingStyle::Raw) => "raw", 34 | _ => "kakoune", 35 | } 36 | } 37 | fn parse(&self, s: String) -> Vec { 38 | match self { 39 | ParseType::Kakoune => parse_kak_style_quoting(&s), 40 | _ => vec![s], 41 | } 42 | } 43 | } 44 | 45 | #[derive(Debug, Clone)] 46 | pub(crate) struct Context { 47 | session: &'static str, 48 | client: Option>, 49 | fifo_out: Arc, 50 | fifo_err: Arc, 51 | } 52 | 53 | impl From<&str> for Context { 54 | fn from(s: &str) -> Self { 55 | let s = String::from(s); 56 | Context::new(Box::leak(s.into_boxed_str()), None) 57 | } 58 | } 59 | 60 | impl Context { 61 | pub fn new(session: &'static str, client: Option) -> Self { 62 | let mut path = std::env::temp_dir(); 63 | path.push(format!("kamp-{session}")); 64 | 65 | Context { 66 | session, 67 | client: client.map(|s| s.into()), 68 | fifo_out: path.with_extension("out").into(), 69 | fifo_err: path.with_extension("err").into(), 70 | } 71 | } 72 | 73 | pub fn set_client(&mut self, client: String) { 74 | self.client = Some(Rc::from(client.into_boxed_str())); 75 | } 76 | 77 | pub fn unset_client(&mut self) { 78 | self.client = None; 79 | } 80 | 81 | pub fn client(&self) -> Option> { 82 | self.client.clone() 83 | } 84 | 85 | pub fn session(&self) -> &'static str { 86 | self.session 87 | } 88 | 89 | pub fn is_draft(&self) -> bool { 90 | self.client.is_none() 91 | } 92 | 93 | pub fn dispatch(self, dispatcher: T, writer: W) -> Result<()> 94 | where 95 | T: super::Dispatcher, 96 | W: std::io::Write, 97 | { 98 | dispatcher.dispatch(self, writer) 99 | } 100 | 101 | pub fn send_kill(self, exit_status: Option) -> Result<()> { 102 | let mut cmd = String::from("kill"); 103 | if let Some(status) = exit_status { 104 | cmd.push(' '); 105 | cmd.push_str(&status.to_string()); 106 | } 107 | 108 | kak::pipe(self.session, cmd) 109 | .map_err(From::from) 110 | .and_then(|status| self.check_status(status)) 111 | } 112 | 113 | pub fn send(&self, buffer_ctx: Option<(String, i32)>, body: impl AsRef) -> Result { 114 | let mut body = Cow::from(body.as_ref()); 115 | let mut cmd = String::from("try %{\n"); 116 | cmd.push_str("eval"); 117 | match (buffer_ctx, self.client()) { 118 | (Some((b, n)), _) => { 119 | cmd.push_str(" -buffer "); 120 | cmd.push_str(&b); 121 | if n != 1 { 122 | body.to_mut().push_str("\necho -to-file %opt{kamp_out} ' '"); 123 | } 124 | } 125 | (_, Some(c)) => { 126 | cmd.push_str(" -client "); 127 | cmd.push_str(&c); 128 | } 129 | _ => (), // 'get val client_list' for example need neither buffer nor client 130 | } 131 | cmd.push_str(" %{\n"); 132 | cmd.push_str(&body); 133 | cmd.push_str("\n}\n"); 134 | write_end_token(&mut cmd); 135 | cmd.push_str("} catch %{\n"); 136 | cmd.push_str("echo -debug kamp: %val{error};"); 137 | cmd.push_str("echo -to-file %opt{kamp_err} %val{error}\n}"); 138 | 139 | let (tx, rx) = sync_channel(0); 140 | let err_h = self.read_fifo_err(tx.clone()); 141 | let out_h = self.read_fifo_out(tx); 142 | 143 | kak::pipe(self.session, cmd) 144 | .map_err(From::from) 145 | .and_then(|status| self.check_status(status))?; 146 | 147 | match rx.recv().map_err(anyhow::Error::new)? { 148 | Err(e) => { 149 | err_h.join().unwrap()?; 150 | Err(e) 151 | } 152 | Ok(s) => { 153 | out_h.join().unwrap()?; 154 | Ok(s) 155 | } 156 | } 157 | } 158 | 159 | pub fn connect(self, body: impl AsRef) -> Result<()> { 160 | let mut cmd = String::new(); 161 | let body = body.as_ref(); 162 | if !body.is_empty() { 163 | cmd.push_str("try %{\neval %{\n"); 164 | cmd.push_str(body); 165 | cmd.push_str("\n}\n"); 166 | write_end_token(&mut cmd); 167 | cmd.push_str("} catch %{\n"); 168 | cmd.push_str("echo -debug kamp: %val{error};"); 169 | cmd.push_str("echo -to-file %opt{kamp_err} %val{error};"); 170 | cmd.push_str("quit 1\n}"); 171 | } else { 172 | write_end_token(&mut cmd); 173 | cmd.pop(); 174 | } 175 | 176 | let (tx, rx) = sync_channel(1); 177 | let err_h = self.read_fifo_err(tx.clone()); 178 | let out_h = self.read_fifo_out(tx); 179 | 180 | let kak_h = thread::spawn(move || kak::connect(self.session, cmd)); 181 | 182 | let res = match rx.recv().map_err(anyhow::Error::new) { 183 | Err(e) => { 184 | return kak_h 185 | .join() 186 | .unwrap() 187 | .map_err(From::from) 188 | .and_then(|status| self.check_status(status)) 189 | .and(Err(e.into())); // fallback to recv error 190 | } 191 | Ok(res) => res, 192 | }; 193 | 194 | match res { 195 | Err(e) => { 196 | err_h.join().unwrap()?; 197 | kak_h.join().unwrap()?; 198 | Err(e) 199 | } 200 | Ok(_) => { 201 | // need to write to err pipe in order to complete its read thread 202 | // send on read_fifo_err side is going to be non blocking because of channel's bound = 1 203 | std::fs::OpenOptions::new() 204 | .write(true) 205 | .open(&self.fifo_err) 206 | .and_then(|mut f| f.write_all(b""))?; 207 | out_h.join().unwrap()?; 208 | kak_h 209 | .join() 210 | .unwrap() 211 | .map_err(From::from) 212 | .and_then(|status| self.check_status(status)) 213 | } 214 | } 215 | } 216 | 217 | pub fn query_val( 218 | &self, 219 | buffer_ctx: Option<(String, i32)>, 220 | name: impl AsRef, 221 | quote: bool, 222 | split: bool, 223 | ) -> Result> { 224 | self.query_kak(buffer_ctx, ("val", name.as_ref()), quote, split) 225 | } 226 | 227 | pub fn query_opt( 228 | &self, 229 | buffer_ctx: Option<(String, i32)>, 230 | name: impl AsRef, 231 | quote: bool, 232 | split: bool, 233 | ) -> Result> { 234 | self.query_kak(buffer_ctx, ("opt", name.as_ref()), quote, split) 235 | } 236 | 237 | pub fn query_reg( 238 | &self, 239 | buffer_ctx: Option<(String, i32)>, 240 | name: impl AsRef, 241 | quote: bool, 242 | split: bool, 243 | ) -> Result> { 244 | self.query_kak(buffer_ctx, ("reg", name.as_ref()), quote, split) 245 | } 246 | 247 | pub fn query_sh( 248 | &self, 249 | buffer_ctx: Option<(String, i32)>, 250 | cmd: impl AsRef, 251 | ) -> Result> { 252 | self.query_kak(buffer_ctx, ("sh", cmd.as_ref()), false, false) 253 | } 254 | 255 | fn query_kak( 256 | &self, 257 | buffer_ctx: Option<(String, i32)>, 258 | (key, val): (&str, &str), 259 | quote: bool, 260 | split: bool, 261 | ) -> Result> { 262 | let parse_type = ParseType::new(quote, split); 263 | let mut buf = String::from("echo -quoting "); 264 | buf.push_str(parse_type.quoting()); 265 | buf.push_str(" -to-file %opt{kamp_out} %"); 266 | buf.push_str(key); 267 | buf.push('{'); 268 | buf.push_str(val); 269 | buf.push('}'); 270 | self.send(buffer_ctx, &buf).map(|s| parse_type.parse(s)) 271 | } 272 | 273 | fn check_status(&self, status: std::process::ExitStatus) -> Result<()> { 274 | if status.success() { 275 | return Ok(()); 276 | } 277 | Err(match status.code() { 278 | Some(code) => Error::InvalidSession { 279 | session: self.session, 280 | exit_code: code, 281 | }, 282 | None => anyhow::Error::msg("kak terminated by signal").into(), 283 | }) 284 | } 285 | 286 | fn read_fifo_err( 287 | &self, 288 | send_ch: SyncSender>, 289 | ) -> thread::JoinHandle> { 290 | let path = self.fifo_err.clone(); 291 | thread::spawn(move || { 292 | let mut buf = String::new(); 293 | std::fs::OpenOptions::new() 294 | .read(true) 295 | .open(path) 296 | .and_then(|mut f| f.read_to_string(&mut buf))?; 297 | send_ch 298 | .send(Err(Error::KakEvalCatch(buf))) 299 | .map_err(anyhow::Error::new) 300 | }) 301 | } 302 | 303 | fn read_fifo_out( 304 | &self, 305 | send_ch: SyncSender>, 306 | ) -> thread::JoinHandle> { 307 | let path = self.fifo_out.clone(); 308 | thread::spawn(move || { 309 | let mut buf = String::new(); 310 | let mut f = std::fs::OpenOptions::new().read(true).open(path)?; 311 | // END_TOKEN comes appended to the payload 312 | let res = loop { 313 | f.read_to_string(&mut buf)?; 314 | if buf.ends_with(END_TOKEN) { 315 | break buf.trim_end_matches(END_TOKEN); 316 | } 317 | }; 318 | send_ch.send(Ok(res.into())).map_err(anyhow::Error::new) 319 | }) 320 | } 321 | } 322 | 323 | fn write_end_token(buf: &mut String) { 324 | buf.push_str("echo -to-file %opt{kamp_out} "); 325 | buf.push_str(END_TOKEN); 326 | buf.push('\n'); 327 | } 328 | 329 | fn parse_kak_style_quoting(input: &str) -> Vec { 330 | let mut res = Vec::new(); 331 | let mut buf = String::new(); 332 | let mut state_is_open = false; 333 | let mut iter = input.chars().peekable(); 334 | loop { 335 | match iter.next() { 336 | Some('\'') => { 337 | if state_is_open { 338 | if let Some('\'') = iter.peek() { 339 | buf.push('\''); 340 | } else { 341 | res.push(buf); 342 | buf = String::new(); 343 | } 344 | state_is_open = false; 345 | } else { 346 | state_is_open = true; 347 | } 348 | } 349 | Some(c) => { 350 | if state_is_open { 351 | buf.push(c) 352 | } 353 | } 354 | None => return res, 355 | } 356 | } 357 | } 358 | 359 | #[cfg(test)] 360 | mod tests { 361 | use super::*; 362 | use std::collections::HashMap; 363 | 364 | #[test] 365 | fn test_parse_kak_style_quoting() { 366 | let test = vec!["'''a'''", "'b'", "'echo pop'", r#"'echo "''ok''"'"#]; 367 | let expected = vec!["'a'", "b", "echo pop", r#"echo "'ok'""#] 368 | .into_iter() 369 | .map(String::from) 370 | .collect::>(); 371 | 372 | assert_eq!(parse_kak_style_quoting(&test.join(" ")), expected); 373 | 374 | let map = test.into_iter().zip(expected).collect::>(); 375 | for (test, expected) in map { 376 | assert_eq!(parse_kak_style_quoting(test), vec![expected]); 377 | } 378 | } 379 | } 380 | -------------------------------------------------------------------------------- /src/kamp/error.rs: -------------------------------------------------------------------------------- 1 | #[derive(thiserror::Error, Debug)] 2 | pub enum Error { 3 | #[error("invalid context: {0}")] 4 | InvalidContext(&'static str), 5 | 6 | #[error("invalid session {session:?} kak exited with code: {exit_code}")] 7 | InvalidSession { 8 | session: &'static str, 9 | exit_code: i32, 10 | }, 11 | 12 | #[error("invalid coordinates: {coord:?}")] 13 | InvalidCoordinates { 14 | coord: String, 15 | source: anyhow::Error, 16 | }, 17 | 18 | #[error("command is required")] 19 | CommandRequired, 20 | 21 | #[error("kak eval error: {0}")] 22 | KakEvalCatch(String), 23 | 24 | #[error(transparent)] 25 | IO(#[from] std::io::Error), 26 | 27 | #[error(transparent)] 28 | Fmt(#[from] std::fmt::Error), 29 | 30 | #[error(transparent)] 31 | Other(#[from] anyhow::Error), // source and Display delegate to anyhow::Error 32 | } 33 | 34 | pub type Result = std::result::Result; 35 | -------------------------------------------------------------------------------- /src/kamp/kak.rs: -------------------------------------------------------------------------------- 1 | use std::io::{Error, Result, Write}; 2 | use std::process::{Command, ExitStatus, Stdio}; 3 | 4 | pub(crate) fn list_sessions() -> Result> { 5 | let output = Command::new("kak").arg("-l").output()?; 6 | 7 | if !output.status.success() { 8 | return Err(match output.status.code() { 9 | Some(code) => Error::other(format!("kak exited with status code: {code}")), 10 | None => Error::other("kak terminated by signal"), 11 | }); 12 | } 13 | 14 | Ok(output.stdout) 15 | } 16 | 17 | pub(crate) fn pipe(session: S, cmd: T) -> Result 18 | where 19 | S: AsRef, 20 | T: AsRef<[u8]>, 21 | { 22 | let mut child = Command::new("kak") 23 | .arg("-p") 24 | .arg(session.as_ref()) 25 | .stdin(Stdio::piped()) 26 | .spawn()?; 27 | 28 | let Some(stdin) = child.stdin.as_mut() else { 29 | return Err(Error::other("cannot capture stdin of kak process")); 30 | }; 31 | 32 | stdin.write_all(cmd.as_ref())?; 33 | child.wait() 34 | } 35 | 36 | pub(crate) fn connect>(session: S, cmd: String) -> Result { 37 | Command::new("kak") 38 | .arg("-c") 39 | .arg(session.as_ref()) 40 | .arg("-e") 41 | .arg(&cmd) 42 | .status() 43 | } 44 | 45 | pub(crate) fn proxy(args: Vec) -> Result<()> { 46 | use std::os::unix::process::CommandExt; 47 | Err(Command::new("kak").args(args).exec()) 48 | } 49 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | mod kamp; 2 | 3 | use anyhow::Result; 4 | 5 | fn main() -> Result<()> { 6 | kamp::run().map_err(From::from) 7 | } 8 | --------------------------------------------------------------------------------