From 8ea70bc11b9f4d5d77c994f2af823d1274671c00 Mon Sep 17 00:00:00 2001 From: myypo Date: Tue, 25 Jun 2024 11:59:16 +0300 Subject: [PATCH] initial commit --- .cargo/config.toml | 11 + .envrc | 1 + .github/ISSUE_TEMPLATE/bug_report.yml | 46 ++ .github/ISSUE_TEMPLATE/feature_request.yml | 29 + .github/workflows/ci-cd.yml | 146 ++++ .github/workflows/vimdoc.yml | 30 + .gitignore | 5 + Cargo.lock | 549 +++++++++++++ Cargo.toml | 15 + Makefile | 69 ++ README.md | 304 ++++++++ compass/Cargo.toml | 24 + compass/src/bootstrap.rs | 165 ++++ compass/src/common_types/cursor_position.rs | 79 ++ compass/src/common_types/extmark.rs | 60 ++ compass/src/common_types/mod.rs | 8 + compass/src/common_types/timestamp.rs | 58 ++ compass/src/config.rs | 44 ++ compass/src/config/common/mod.rs | 2 + compass/src/config/common/sign.rs | 26 + compass/src/config/frecency.rs | 100 +++ compass/src/config/marks.rs | 15 + compass/src/config/marks/signs.rs | 16 + compass/src/config/marks/update_range.rs | 42 + compass/src/config/persistence.rs | 195 +++++ compass/src/config/picker.rs | 122 +++ compass/src/config/picker/filename.rs | 26 + compass/src/config/picker/jump_keymaps.rs | 76 ++ compass/src/config/picker/window_grid_size.rs | 116 +++ compass/src/config/tracker.rs | 19 + compass/src/error.rs | 48 ++ compass/src/frecency/frecency_record.rs | 32 + compass/src/frecency/frecency_type.rs | 25 + compass/src/frecency/frecency_weight.rs | 35 + compass/src/frecency/mod.rs | 52 ++ compass/src/functions/follow/completion.rs | 13 + compass/src/functions/follow/mod.rs | 154 ++++ compass/src/functions/follow/opts.rs | 83 ++ compass/src/functions/goto/completion.rs | 14 + compass/src/functions/goto/mod.rs | 82 ++ compass/src/functions/goto/opts.rs | 174 +++++ compass/src/functions/mod.rs | 17 + compass/src/functions/open/completion.rs | 3 + compass/src/functions/open/mod.rs | 191 +++++ compass/src/functions/open/opts.rs | 79 ++ compass/src/functions/place/completion.rs | 13 + compass/src/functions/place/mod.rs | 74 ++ compass/src/functions/place/opts.rs | 56 ++ compass/src/functions/pop/completion.rs | 13 + compass/src/functions/pop/mod.rs | 35 + compass/src/functions/pop/opts.rs | 73 ++ compass/src/functions/setup.rs | 34 + compass/src/lib.rs | 23 + compass/src/state/mod.rs | 17 + compass/src/state/namespace.rs | 55 ++ compass/src/state/record.rs | 185 +++++ compass/src/state/session.rs | 69 ++ compass/src/state/session/data_session.rs | 110 +++ compass/src/state/track_list.rs | 720 ++++++++++++++++++ compass/src/state/tracker.rs | 202 +++++ compass/src/state/worker.rs | 28 + compass/src/ui/ensure_buf_loaded.rs | 23 + compass/src/ui/grid.rs | 268 +++++++ compass/src/ui/highlights.rs | 257 +++++++ compass/src/ui/mod.rs | 9 + compass/src/ui/record_mark.rs | 340 +++++++++ compass/src/ui/tab.rs | 25 + compass/src/viml/compass_args.rs | 85 +++ compass/src/viml/mod.rs | 2 + doc/compass.nvim.txt | 228 ++++++ flake.lock | 286 +++++++ flake.nix | 71 ++ lua/.gitkeep | 0 macros/Cargo.toml | 12 + macros/src/lib.rs | 109 +++ 75 files changed, 6822 insertions(+) create mode 100644 .cargo/config.toml create mode 100644 .envrc create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml create mode 100644 .github/workflows/ci-cd.yml create mode 100644 .github/workflows/vimdoc.yml create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 Makefile create mode 100644 README.md create mode 100644 compass/Cargo.toml create mode 100644 compass/src/bootstrap.rs create mode 100644 compass/src/common_types/cursor_position.rs create mode 100644 compass/src/common_types/extmark.rs create mode 100644 compass/src/common_types/mod.rs create mode 100644 compass/src/common_types/timestamp.rs create mode 100644 compass/src/config.rs create mode 100644 compass/src/config/common/mod.rs create mode 100644 compass/src/config/common/sign.rs create mode 100644 compass/src/config/frecency.rs create mode 100644 compass/src/config/marks.rs create mode 100644 compass/src/config/marks/signs.rs create mode 100644 compass/src/config/marks/update_range.rs create mode 100644 compass/src/config/persistence.rs create mode 100644 compass/src/config/picker.rs create mode 100644 compass/src/config/picker/filename.rs create mode 100644 compass/src/config/picker/jump_keymaps.rs create mode 100644 compass/src/config/picker/window_grid_size.rs create mode 100644 compass/src/config/tracker.rs create mode 100644 compass/src/error.rs create mode 100644 compass/src/frecency/frecency_record.rs create mode 100644 compass/src/frecency/frecency_type.rs create mode 100644 compass/src/frecency/frecency_weight.rs create mode 100644 compass/src/frecency/mod.rs create mode 100644 compass/src/functions/follow/completion.rs create mode 100644 compass/src/functions/follow/mod.rs create mode 100644 compass/src/functions/follow/opts.rs create mode 100644 compass/src/functions/goto/completion.rs create mode 100644 compass/src/functions/goto/mod.rs create mode 100644 compass/src/functions/goto/opts.rs create mode 100644 compass/src/functions/mod.rs create mode 100644 compass/src/functions/open/completion.rs create mode 100644 compass/src/functions/open/mod.rs create mode 100644 compass/src/functions/open/opts.rs create mode 100644 compass/src/functions/place/completion.rs create mode 100644 compass/src/functions/place/mod.rs create mode 100644 compass/src/functions/place/opts.rs create mode 100644 compass/src/functions/pop/completion.rs create mode 100644 compass/src/functions/pop/mod.rs create mode 100644 compass/src/functions/pop/opts.rs create mode 100644 compass/src/functions/setup.rs create mode 100644 compass/src/lib.rs create mode 100644 compass/src/state/mod.rs create mode 100644 compass/src/state/namespace.rs create mode 100644 compass/src/state/record.rs create mode 100644 compass/src/state/session.rs create mode 100644 compass/src/state/session/data_session.rs create mode 100644 compass/src/state/track_list.rs create mode 100644 compass/src/state/tracker.rs create mode 100644 compass/src/state/worker.rs create mode 100644 compass/src/ui/ensure_buf_loaded.rs create mode 100644 compass/src/ui/grid.rs create mode 100644 compass/src/ui/highlights.rs create mode 100644 compass/src/ui/mod.rs create mode 100644 compass/src/ui/record_mark.rs create mode 100644 compass/src/ui/tab.rs create mode 100644 compass/src/viml/compass_args.rs create mode 100644 compass/src/viml/mod.rs create mode 100644 doc/compass.nvim.txt create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 lua/.gitkeep create mode 100644 macros/Cargo.toml create mode 100644 macros/src/lib.rs diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..d47f983 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,11 @@ +[target.x86_64-apple-darwin] +rustflags = [ + "-C", "link-arg=-undefined", + "-C", "link-arg=dynamic_lookup", +] + +[target.aarch64-apple-darwin] +rustflags = [ + "-C", "link-arg=-undefined", + "-C", "link-arg=dynamic_lookup", +] diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..3aa665a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,46 @@ +name: Bug Report +description: Open a bug related issue +title: "bug: " +labels: [bug] +body: + - type: markdown + attributes: + value: | + Before reporting an issue, please, make sure to read the [documentation](https://github.com/myypo/compass.nvim) and read through all of the [open and closed issues](https://github.com/myypo/compass.nvim/issues) as well as any existing [discussions](https://github.com/myypo/compass.nvim/discussions) + - type: checkboxes + attributes: + label: Did you check docs and existing issues? + description: Make sure you checked all of the below before submitting an issue + options: + - label: I have read the plugin's documentation + required: true + - label: I have searched through existing issues of the plugin, as well as discussions + required: true + - type: textarea + attributes: + label: Your platform and neovim version + description: The OS where the bug was encountered and the output of `nvim --version` + validations: + required: true + - type: textarea + attributes: + label: Describe the bug + description: A clear and concise description of what the bug is. Please include any related errors you see in Neovim. + validations: + required: true + - type: textarea + attributes: + label: Steps To Reproduce + description: Steps to reproduce the behavior. + placeholder: | + 1. + 2. + 3. + validations: + required: true + - type: textarea + attributes: + label: Expected Behavior + description: A concise description of what you expected to happen. + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..41524d1 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,29 @@ +name: Feature Request +description: Suggest a new feature +title: "feature: " +labels: [enhancement] +body: + - type: checkboxes + attributes: + label: Did you check the docs? + options: + - label: I have read all of the plugin's docs + required: true + - type: checkboxes + attributes: + label: Are there no similiar feature requests? + options: + - label: There are no existing requests for this feature + required: true + - type: textarea + validations: + required: true + attributes: + label: Describe the feature and solution you'd like + description: A clear and concise description of what you want to happen. + - type: textarea + validations: + required: false + attributes: + label: Additional context + description: Add any other context or screenshots about the feature request here. diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml new file mode 100644 index 0000000..f67653d --- /dev/null +++ b/.github/workflows/ci-cd.yml @@ -0,0 +1,146 @@ +name: CI/CD + +on: + push: + branches: + - main + paths-ignore: + - README.md + pull_request: + branches: + - main + paths-ignore: + - README.md + schedule: + - cron: "0 12 * * *" + workflow_dispatch: + inputs: + tag_name: + description: "Tag name for release" + required: false + default: nightly + +jobs: + test: + timeout-minutes: 5 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + neovim: [stable, nightly] + + runs-on: ${{ matrix.os }} + + steps: + - uses: actions/checkout@v4 + - name: Install Neovim ${{ matrix.neovim }} + uses: rhysd/action-setup-vim@v1 + with: + neovim: true + version: ${{ matrix.neovim }} + - name: Install latest nightly rustc + uses: dtolnay/rust-toolchain@nightly + - name: Run unit tests + run: | + cargo build + cargo test + + clippy: + name: clippy + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@nightly + with: + components: clippy + - run: cargo clippy -- -D warnings + + format: + name: format + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@nightly + with: + components: rustfmt + - run: cargo fmt --check + + build: + needs: [test, clippy, format] + name: build for ${{ matrix.platform.os_name }} + runs-on: ${{ matrix.platform.os }} + permissions: + contents: write + strategy: + fail-fast: false + matrix: + platform: + - os_name: linux-x86_64 + os: ubuntu-latest + target: x86_64-unknown-linux-gnu + lib_name: libcompass + lib_extension: so + + - os_name: mac-x86_64 + os: macos-latest + target: x86_64-apple-darwin + lib_name: libcompass + lib_extension: dylib + + - os_name: mac-aarch64 + os: macos-latest + target: aarch64-apple-darwin + lib_name: libcompass + lib_extension: dylib + + - os_name: windows-x86_64 + os: windows-latest + target: x86_64-pc-windows-msvc + lib_name: compass + lib_extension: dll + steps: + - uses: actions/checkout@v4 + - name: Build libraries + uses: houseabsolute/actions-rust-cross@v0 + with: + command: "build" + target: ${{ matrix.platform.target }} + toolchain: nightly + args: "--locked --release" + strip: true + - name: Upload libraries + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.platform.os_name }} + path: target/${{ matrix.platform.target }}/release/${{ matrix.platform.lib_name }}.${{ matrix.platform.lib_extension }} + publish: + needs: build + runs-on: ubuntu-latest + env: + GH_REPO: ${{ github.repository }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + + - uses: actions/download-artifact@v4 + + - run: | + mv linux-x86_64/libcompass.so linux-x86_64.so + mv mac-aarch64/libcompass.dylib mac-aarch64.dylib + mv mac-x86_64/libcompass.dylib mac-x86_64.dylib + mv windows-x86_64/compass.dll windows-x86_64.dll + gh release delete nightly --yes || true + + - name: release + uses: softprops/action-gh-release@v2 + with: + prerelease: true + make_latest: true + tag_name: nightly + files: | + linux-x86_64.so + mac-aarch64.dylib + mac-x86_64.dylib + windows-x86_64.dll diff --git a/.github/workflows/vimdoc.yml b/.github/workflows/vimdoc.yml new file mode 100644 index 0000000..cff6d65 --- /dev/null +++ b/.github/workflows/vimdoc.yml @@ -0,0 +1,30 @@ +name: Generate vimdoc + +on: + push: + branches: + - main + paths: + - README.md + +jobs: + docs: + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + - name: Generate panvimdoc + uses: kdheepak/panvimdoc@main + with: + vimdoc: compass.nvim + version: "Neovim >= 0.8.0" + demojify: true + treesitter: true + - name: Commit generated doc + uses: stefanzweifel/git-auto-commit-action@v5 + with: + commit_message: "chore(build): auto-generate vimdoc" + commit_user_name: "github-actions[bot]" + commit_user_email: "github-actions[bot]@users.noreply.github.com" + commit_author: "github-actions[bot] " diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0fbd120 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +target +result + +lua/* +!lua/.gitkeep diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..1c35493 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,549 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.86" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3d1d046238990b9cf5bcde22a3fb3584ee5cf65fb2765f454ed428c7a0063da" + +[[package]] +name = "autocfg" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c4b4d0bd25bd0b74681c0ad21497610ce1b7c91b1022cd21c80c6fbdd9476b0" + +[[package]] +name = "bitcode" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48bc1c27654127a24c476d40198746860ef56475f41a601bfa5c4d0f832968f0" +dependencies = [ + "bitcode_derive", + "bytemuck", +] + +[[package]] +name = "bitcode_derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2966755a19aad59ee2aae91e2d48842c667a99d818ec72168efdab07200701cc" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "bumpalo" +version = "3.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" + +[[package]] +name = "bytemuck" +version = "1.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b236fc92302c97ed75b38da1f4917b5cdda4984745740f153a5d3059e48d725e" + +[[package]] +name = "cc" +version = "1.0.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c51067fd44124faa7f870b4b1c969379ad32b2ba805aa959430ceaa384f695" + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "chrono" +version = "0.4.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a21f936df1771bf62b77f047b726c4625ff2e8aa607c01ec06e5a05bd8463401" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-targets", +] + +[[package]] +name = "compass" +version = "0.1.0" +dependencies = [ + "anyhow", + "bitcode", + "chrono", + "macros", + "nvim-oxi", + "serde", + "serde_json", + "strum", + "strum_macros", + "thiserror", + "typed-builder", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06ea2b9bc92be3c2baa9334a323ebca2d6f074ff852cd1d7b11064035cd3868f" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "iana-time-zone" +version = "0.1.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7ffbb5a1b541ea2561f8c41c087286cc091e21e556a4f09a8f6cbf17b69b141" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "itoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" + +[[package]] +name = "js-sys" +version = "0.3.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29c15563dc2726973df627357ce0c9ddddbea194836909d655df6a75d2cf296d" +dependencies = [ + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.155" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97b3888a4aecf77e811145cadf6eef5901f4782c53886191b2f693f24761847c" + +[[package]] +name = "log" +version = "0.4.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90ed8c1e510134f979dbc4f070f87d4313098b704861a105fe34231c70a3901c" + +[[package]] +name = "macros" +version = "0.0.0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "mini-internal" +version = "0.1.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28c501b91dabb672c4b303fb6ea259022ab21ed8d06a457ac21b70e479fb367" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "miniserde" +version = "0.1.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd044c3b41ba1d92e3dbeef687ecfe5d1dcbd8b4fa1d33610f5ce5546ad6aac1" +dependencies = [ + "itoa", + "mini-internal", + "ryu", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "nvim-oxi" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13587f8434641462a0674bc575fb1ca88d66827118470df3c2608e769fc8e3ee" +dependencies = [ + "miniserde", + "nvim-oxi-api", + "nvim-oxi-luajit", + "nvim-oxi-macros", + "nvim-oxi-types", + "thiserror", +] + +[[package]] +name = "nvim-oxi-api" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0926b8fac04e376a743a7519382ef21c9d6e3ef584338d439972b911e5d85554" +dependencies = [ + "nvim-oxi-luajit", + "nvim-oxi-macros", + "nvim-oxi-types", + "serde", + "serde_repr", + "thiserror", +] + +[[package]] +name = "nvim-oxi-luajit" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3d83bb0ca6908c82bad8b22379f7577d0d82aa050601c0dce15d7c691c2131a" +dependencies = [ + "thiserror", +] + +[[package]] +name = "nvim-oxi-macros" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0db91ed44967f43c9ac9f80ce629391f53787aac6212aac79966422940671f9" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "nvim-oxi-types" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2545fa96873e7bc0bd12e1e68582a89ca593fad872e996e479a8615ae986424" +dependencies = [ + "libc", + "nvim-oxi-luajit", + "serde", + "thiserror", +] + +[[package]] +name = "once_cell" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" + +[[package]] +name = "proc-macro2" +version = "1.0.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22244ce15aa966053a896d1accb3a6e68469b97c7f33f284b99f0d576879fc23" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa76aaf39101c457836aec0ce2316dbdc3ab723cdda1c6bd4e6ad4208acaca7" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rustversion" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "955d28af4278de8121b7ebeb796b6a45735dc01436d898801014aced2773a3d6" + +[[package]] +name = "ryu" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" + +[[package]] +name = "serde" +version = "1.0.203" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7253ab4de971e72fb7be983802300c30b5a7f0c2e56fab8abfc6a214307c0094" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.203" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "500cbc0ebeb6f46627f50f3f5811ccf6bf00643be300b4c3eabc0ef55dc5b5ba" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "455182ea6142b14f93f4bc5320a2b31c1f266b66a4a5c858b013302a5d8cbfc3" +dependencies = [ + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_repr" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c64451ba24fc7a6a2d60fc75dd9c83c90903b19028d4eff35e88fc1e86564e9" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "strum" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d8cec3501a5194c432b2b7976db6b7d10ec95c253208b45f83f7136aa985e29" + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + +[[package]] +name = "syn" +version = "2.0.66" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c42f3f41a2de00b01c0aaad383c5a45241efc8b2d1eda5661812fda5f3cdcff5" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "1.0.61" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c546c80d6be4bc6a00c0f01730c08df82eaa7a7a61f11d656526506112cc1709" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.61" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c3384250002a6d5af4d114f2845d37b57521033f30d5c3f46c4d70e1197533" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "typed-builder" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77739c880e00693faef3d65ea3aad725f196da38b22fdc7ea6ded6e1ce4d3add" +dependencies = [ + "typed-builder-macro", +] + +[[package]] +name = "typed-builder-macro" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f718dfaf347dcb5b983bfc87608144b0bad87970aebcbea5ce44d2a30c08e63" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "unicode-ident" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4be2531df63900aeb2bca0daaaddec08491ee64ceecbee5076636a3b026795a8" +dependencies = [ + "cfg-if", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "614d787b966d3989fa7bb98a654e369c762374fd3213d212cfc0251257e747da" +dependencies = [ + "bumpalo", + "log", + "once_cell", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1f8823de937b71b9460c0c34e25f3da88250760bec0ebac694b49997550d726" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e94f17b526d0a461a191c78ea52bbce64071ed5c04c9ffe424dcb38f74171bb7" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af190c94f2773fdb3729c55b007a722abb5384da03bc0986df4c289bf5567e96" + +[[package]] +name = "windows-core" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f0713a46559409d202e70e28227288446bf7841d3211583a4b53e3f6d96e7eb" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7088eed71e8b8dda258ecc8bac5fb1153c5cffaf2578fc8ff5d61e23578d3263" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9985fd1504e250c615ca5f281c3f7a6da76213ebd5ccc9561496568a2752afb6" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88ba073cf16d5372720ec942a8ccbf61626074c6d4dd2e745299726ce8b89670" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87f4261229030a858f36b459e748ae97545d6f1ec60e5e0d6a3d32e0dc232ee9" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db3c2bf3d13d5b658be73463284eaf12830ac9a26a90c717b7f771dfe97487bf" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e4246f76bdeff09eb48875a0fd3e2af6aada79d409d33011886d3e1581517d9" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "852298e482cd67c356ddd9570386e2862b5673c85bd5f88df9ab6802b334c596" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bec47e5bfd1bff0eeaf6d8b485cc1074891a197ab4225d504cb7a1ab88b02bf0" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..7c74c5c --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,15 @@ +cargo-features = ["profile-rustflags"] + +[workspace] +resolver = "2" +members = [ + "compass", + "macros", +] + +[profile.dev] +rustflags = ["-Z", "threads=8"] + +[workspace.dependencies] +strum = "0.26.2" +strum_macros = "0.26.2" diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..900044d --- /dev/null +++ b/Makefile @@ -0,0 +1,69 @@ +.PHONY: download build test watch lint + +ifeq ($(OS),Windows_NT) + MACHINE = WIN32 + ifeq ($(PROCESSOR_ARCHITECTURE),AMD64) + MACHINE += AMD64 + endif +else + UNAME_S := $(shell uname -s) + ifeq ($(UNAME_S),Linux) + MACHINE = LINUX + endif + ifeq ($(UNAME_S),Darwin) + MACHINE = OSX + endif + UNAME_P := $(shell uname -p) + ifeq ($(UNAME_P),x86_64) + MACHINE += AMD64 + endif + ifneq ($(filter arm%,$(UNAME_P)),) + MACHINE += ARM + endif +endif + +TARGET = linux-x86_64 +LIB_NAME = libcompass +LIB_EXTENSION = so + +ifeq ($(MACHINE),WIN32 AMD64) + TARGET = windows-x86_64 + LIB_NAME = compass + LIB_EXTENSION = dll + COPY_COMMAND = copy +else ifeq ($(MACHINE),LINUX AMD64) + TARGET = linux-x86_64 + LIB_NAME = libcompass + LIB_EXTENSION = so + COPY_COMMAND = cp -f +else ifeq ($(MACHINE),OSX AMD64) + TARGET = mac-x86_64 + LIB_NAME = libcompass + LIB_EXTENSION = dylib + COPY_COMMAND = cp -f +else ifeq ($(MACHINE),OSX ARM) + TARGET = mac-aarch64 + LIB_NAME = libcompass + LIB_EXTENSION = dylib + COPY_COMMAND = cp -f +else + COPY_COMMAND = cp -f +endif + +download: + curl -L -o ./lua/compass.$(LIB_EXTENSION) https://github.com/myypo/compass.nvim/releases/download/nightly/$(TARGET).$(LIB_EXTENSION) + +build: + cargo build --release + $(COPY_COMMAND) ./target/release/$(LIB_NAME).$(LIB_EXTENSION) ./lua/compass.$(LIB_EXTENSION) + +test: + cargo build + $(COPY_COMMAND) ./target/debug/$(LIB_NAME).$(LIB_EXTENSION) ./lua/compass.$(LIB_EXTENSION) + cargo test + +watch: + cargo-watch -i lua -- make test + +lint: + cargo clippy -- -D warnings diff --git a/README.md b/README.md new file mode 100644 index 0000000..10f800f --- /dev/null +++ b/README.md @@ -0,0 +1,304 @@ +## 🎯 Requirements + +- **Linux x86_64**, **MacOS ARM64/x86_64** or **Windows x86_64**. + +- Neovim **v0.10.0** or **nightly**. Earlier versions are unlikely to work. + +## 🔌 Installation + +### [lazy.nvim](https://github.com/folke/lazy.nvim) + +```lua +{ + "myypo/compass.nvim", + build = "make", + event = "BufReadPost", +} +``` + +The plugin uses [nvim-oxi](https://github.com/noib3/nvim-oxi) to use Rust as the plugin language. + +The provided above installation snippet will download a pre-built by GitHub action library which should +work out of the box, but you can also build it yourself, provided, you have Rust toolchain installed. +To do so just change `build = "make"` to `build = "make build"`. + +## ⌨️ Keymaps + +Example useful keymaps for **Lazy.nvim** + +```lua +keys = { + -- Choose between previous locations in all buffers to where to jump to + { "", "Compass open" }, + + -- Choose between locations in the current buffer to jump to + { "", "Compass follow buf" }, + + -- Move back to the most recent created/updated location + { "", "Compass goto relative direction=back" }, + -- Move forward to the next location + { "", "Compass goto relative direction=forward" }, + + -- Like goto but also deletes that plugin mark + { "", "Compass pop relative direction=back" }, + { "", "Compass pop relative direction=forward" }, + + -- Manually place a change mark that works the same way as automatically put ones + { "", "Compass place change" }, + -- Same as above but always creates a new mark instead of trying to update a nearby one + { "", "Compass place change try_update=false" }, +}, + +``` + +## ⚙️ Configuration + +Default configuration: + +```lua +{ + -- Options for customizing the UI that allows to preview and jump to one of the plugin marks + picker = { + max_windows = 6, -- Limit of windows to be opened, must be an even number + + -- List of keys to be used to jump to a plugin mark + -- Length of this table must be equal or bigger than `max_windows` + jump_keys = { + -- First key is for previewing more jump marks in the file + -- Second key is to immediately jump to this mark + { "j", "J" }, + { "f", "F" }, + { "k", "K" }, + { "d", "D" }, + { "l", "L" }, + { "s", "S" }, + { ";", ":" }, + { "a", "A" }, + { "h", "H" }, + { "g", "G" }, + }, + + filename = { + enable = true, -- Whether to preview filename of the buffer next to the picker hint + depth = 2, -- How many components of the path to show, `2` only shows the filename and the name of the parent directory + }, + }, + + -- Options for the plugin marks + marks = { + -- When applicable, how close an old plugin mark has to be to the newly placed one + -- for the old one to be moved to the new position instead of actually creating a new seperate mark + -- Absence of a defined value means that the condition will always evaluate to false + update_range = { + lines = { + single_max_distance = 10, -- If closer than this number of lines update the existing mark + -- If closer than this number of lines AND `columns.combined_max_distance` is closer + -- than its respective number of columns update the existing mark + combined_max_distance = 25, + }, + columns = { + single_max_distance = nil, -- If closer than this number of columns update the existing mark + combined_max_distance = 25, + }, + }, + + -- Which signs to use for different mark types relatively to the current position + signs = { + past = "●", + close_past = "●", + future = "●", + close_future = "●", + }, + }, + + -- Customization of the tracker automatically placing plugin marks on file edits etc. + tracker = { + enable = true, + }, + + -- Plugin state persistence options + persistence = { + enable = true, -- Whether to persist the plugin state on the disk + path = nil, -- Absolute path to where to persist the state, by default it assumes the default neovim share path + }, + + -- Weights and options for the frecency algorithm + -- When appropriate tries to prioritize showing most used and most recently used plugin marks, for example, in a picker UI + frecency = { + time_bucket = { + -- This table can be of any length + thresholds = { + { hours = 4, weight = 100 }, + { hours = 14, weight = 70 }, + { hours = 31, weight = 50 }, + { hours = 90, weight = 30 }, + } + fallback = 10, -- Default weight when a value is older than the biggest `hours` in `thresholds` + }, + -- Weights for different types of interaction with the mark + visit_type = { + create = 50, + update = 100, + relative_goto = 50, + absolute_goto = 100, + }, + -- Interactions that happen earlier than the cooldown won't be taken into accont when calculating marks' weights + cooldown_seconds = 60, + }, +} + +``` + +## 🎨 Highlights + +The plugin defines highlights that can be overwritten by colorschemes or manually with: `vim.api.nvim_set_hl` + +
+ Marks down the stack + + + + + + + + + + + + + + + + + + + + +
Highlight Default
CompassRecordPast + +``` +guibg=grey gui=bold +``` + +
CompassRecordPastSign + +``` +guibg=grey gui=bold +``` + +
CompassRecordClosePast + +``` +guifg=red gui=bold +``` + +
CompassRecordClosePastSign + +``` +guifg=red gui=bold +``` + +
+ +
+ +
+ Marks up the stack + + + + + + + + + + + + + + + + + + + + +
Highlight Default
CompassRecordFuture + +``` +guibg=grey gui=bold +``` + +
CompassRecordFutureSign + +``` +guibg=grey gui=bold +``` + +
CompassRecordCloseFuture + +``` +guifg=blue gui=bold +``` + +
CompassRecordCloseFutureSign + +``` +guifg=blue gui=bold +``` + +
+ +
+ +
+ Picker window for `open` and `follow` commands + + + + + + + + + + + + + + + + + + + + +
Highlight Default
CompassHintOpen + +``` +guifg=black guibg=yellow gui=bold +``` + +
CompassHintOpenPath + +``` +guifg=black gui=bold +``` + +
CompassHintFollow + +``` +guifg=black guibg=yellow gui=bold +``` + +
CompassHintFollowPath + +``` +guifg=black gui=bold +``` + +
+ +
diff --git a/compass/Cargo.toml b/compass/Cargo.toml new file mode 100644 index 0000000..0c14cf0 --- /dev/null +++ b/compass/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "compass" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +macros = { path = "../macros" } +nvim-oxi = { version = "0.5.1", features = ["neovim-0-10", "test"] } + +serde = "1.0.201" +serde_json = "1.0.117" + +anyhow = "1.0.83" +thiserror = "1.0.60" + +typed-builder = "0.18.2" +chrono = { version = "0.4.38" } +bitcode = "0.6.0" + +strum = { workspace = true } +strum_macros = { workspace = true } diff --git a/compass/src/bootstrap.rs b/compass/src/bootstrap.rs new file mode 100644 index 0000000..b651293 --- /dev/null +++ b/compass/src/bootstrap.rs @@ -0,0 +1,165 @@ +use crate::{ + functions::{ + follow::{get_follow, get_follow_completion}, + goto::{get_goto, get_goto_completion}, + open::{get_open, get_open_completion}, + place::{get_place, get_place_completion}, + pop::{get_pop, get_pop_completion}, + setup::get_setup, + CommandNames, + }, + state::SyncTracker, + viml::CompassArgs, + InputError, Result, +}; +use std::str::FromStr; + +use nvim_oxi::{ + api::{ + create_user_command, + opts::{CreateCommandOpts, SetKeymapOpts}, + set_keymap, + types::{CommandArgs, CommandComplete, CommandNArgs, Mode}, + }, + Dictionary, Function, +}; +use strum::VariantNames; + +/// Initialize the plugin as a Lua map-like table +/// This function should never return an error +pub fn init() -> Result { + let mut dict = Dictionary::new(); + + let tracker = SyncTracker::default(); + + // Attaching plugin-defined functions to the lua table + let setup = get_setup(tracker.clone()); + dict.insert("setup", Function::<_, Result<_>>::from_fn_once(setup)); + + let open = get_open(tracker.clone()); + dict.insert("open", Function::<_, Result<_>>::from_fn(open)); + + let goto = get_goto(tracker.clone()); + dict.insert("goto", Function::<_, Result<_>>::from_fn(goto)); + + let pop = get_pop(tracker.clone()); + dict.insert("pop", Function::<_, Result<_>>::from_fn(pop)); + + let follow = get_follow(tracker.clone()); + dict.insert("follow", Function::<_, Result<_>>::from_fn(follow)); + + // Setting up `Compass COMMAND` user-comands + user_commands(tracker)?; + + Ok(dict) +} + +fn user_commands(tracker: SyncTracker) -> Result<()> { + let goto = get_goto(tracker.clone()); + let pop = get_pop(tracker.clone()); + let open = get_open(tracker.clone()); + let place = get_place(tracker.clone()); + let follow = get_follow(tracker); + + let subcommands = move |ca: CommandArgs| -> Result<()> { + let cargs = + CompassArgs::try_from(ca.fargs.iter().map(AsRef::as_ref).collect::>())?; + + match CommandNames::from_str(cargs.main_cmd).map_err(|_| { + InputError::FunctionArguments( + format!("provided unknown compass subcommand: {}", cargs.main_cmd).to_owned(), + ) + })? { + CommandNames::Goto => Ok(goto(Some(cargs.try_into()?))?), + CommandNames::Pop => Ok(pop(Some(cargs.try_into()?))?), + CommandNames::Open => Ok(open(Some(cargs.try_into()?))?), + CommandNames::Place => Ok(place(Some(cargs.try_into()?))?), + CommandNames::Follow => Ok(follow(Some(cargs.try_into()?))?), + } + }; + + create_user_command( + "Compass", + Function::from_fn_mut(subcommands), + &CreateCommandOpts::builder() + .nargs(CommandNArgs::OneOrMore) + .complete(cmd_completion()) + .build(), + )?; + + plug_keymaps()?; + + Ok(()) +} + +fn cmd_completion() -> CommandComplete { + CommandComplete::CustomList(Function::from(|(_, full, _): (String, String, usize)| { + let full = full.replace("Compass", ""); + let full: Vec<&str> = full.split_whitespace().collect(); + + let Ok(cargs) = TryInto::::try_into(full) else { + return CommandNames::VARIANTS + .iter() + .map(|&s| s.to_owned()) + .collect::>(); + }; + let Ok(cmd) = CommandNames::from_str(cargs.main_cmd) else { + return CommandNames::VARIANTS + .iter() + .map(|&s| s.to_owned()) + .collect::>(); + }; + + match cmd { + CommandNames::Goto => get_goto_completion(&cargs), + CommandNames::Pop => get_pop_completion(&cargs), + CommandNames::Open => get_open_completion(), + CommandNames::Place => get_place_completion(&cargs), + CommandNames::Follow => get_follow_completion(&cargs), + } + })) +} + +fn plug_keymaps() -> Result<()> { + set_keymap( + Mode::Normal, + "(CompassOpenAll)", + ":Compass open all", + &SetKeymapOpts::builder().noremap(true).build(), + )?; + + set_keymap( + Mode::Normal, + "(CompassGotoBack)", + ":Compass goto relative direction=back", + &SetKeymapOpts::builder().noremap(true).build(), + )?; + set_keymap( + Mode::Normal, + "(CompassGotoForward)", + ":Compass goto relative direction=forward", + &SetKeymapOpts::builder().noremap(true).build(), + )?; + + set_keymap( + Mode::Normal, + "(CompassPopBack)", + ":Compass pop relative direction=back", + &SetKeymapOpts::builder().noremap(true).build(), + )?; + set_keymap( + Mode::Normal, + "(CompassPopForward)", + ":Compass pop relative direction=forward", + &SetKeymapOpts::builder().noremap(true).build(), + )?; + + set_keymap( + Mode::Normal, + "(CompassPlaceChange)", + ":Compass place change", + &SetKeymapOpts::builder().noremap(true).build(), + )?; + + Ok(()) +} diff --git a/compass/src/common_types/cursor_position.rs b/compass/src/common_types/cursor_position.rs new file mode 100644 index 0000000..e56820f --- /dev/null +++ b/compass/src/common_types/cursor_position.rs @@ -0,0 +1,79 @@ +use crate::config::get_config; + +use bitcode::{Decode, Encode}; +use serde::{Deserialize, Serialize}; + +/// 1,0 indexed in the editor +#[derive(Clone, Debug, Hash, PartialEq, Eq, Deserialize, Serialize, Decode, Encode)] +pub struct CursorPosition { + pub line: usize, + pub col: usize, +} + +impl CursorPosition { + pub fn is_nearby(&self, &Self { line, col }: &Self) -> bool { + let conf = &get_config().marks.update_range; + + let near_line = { + if let Some(opt_lines) = &conf.lines { + if opt_lines + .single_max_distance + .is_some_and(|single| single >= self.line.abs_diff(line)) + { + return true; + } else { + opt_lines + .combined_max_distance + .is_some_and(|combined| combined >= self.line.abs_diff(line)) + } + } else { + true + } + }; + + let near_col = { + if let Some(opt_cols) = &conf.columns { + if opt_cols + .single_max_distance + .is_some_and(|single| single >= self.col.abs_diff(col)) + { + return true; + } else { + opt_cols + .combined_max_distance + .is_some_and(|combined| combined >= self.col.abs_diff(col)) + } + } else { + true + } + }; + + near_line && near_col + } +} + +impl From<(usize, usize)> for CursorPosition { + fn from((line, col): (usize, usize)) -> Self { + Self { line, col } + } +} + +mod tests { + use super::*; + + #[nvim_oxi::test] + fn can_identify_close_same_line_position() { + let pos1: CursorPosition = (420, 0).into(); + let pos2: CursorPosition = (420, 6969).into(); + + assert!(pos1.is_nearby(&pos2)) + } + + #[nvim_oxi::test] + fn can_identify_close_multi_line_position() { + let pos1: CursorPosition = (29, 42).into(); + let pos2: CursorPosition = (28, 45).into(); + + assert!(pos1.is_nearby(&pos2)) + } +} diff --git a/compass/src/common_types/extmark.rs b/compass/src/common_types/extmark.rs new file mode 100644 index 0000000..6649dc6 --- /dev/null +++ b/compass/src/common_types/extmark.rs @@ -0,0 +1,60 @@ +use std::hash::Hash; + +use crate::{common_types::CursorPosition, state::get_namespace, Result}; + +use nvim_oxi::api::{opts::GetExtmarkByIdOpts, Buffer}; + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct Extmark { + id: u32, + init_pos: CursorPosition, +} + +impl Extmark { + pub fn try_new(id: u32, buf: Buffer) -> Result { + let init_pos = get_extmark_pos(id, buf)?; + + Ok(Self { id, init_pos }) + } + + pub fn new(id: u32, init_pos: CursorPosition) -> Self { + Self { id, init_pos } + } + + pub fn get_pos(&self, buf: Buffer) -> CursorPosition { + let Ok(pos) = get_extmark_pos(self.id, buf) else { + return self.init_pos.clone(); + }; + + // HACK: when (2, 0) we assume the buf was not yet properly opened + if pos == (2, 0).into() { + self.init_pos.clone() + } else { + pos + } + } + + pub fn delete(&self, mut buf: Buffer) -> Result<()> { + Ok(buf.del_extmark(get_namespace().into(), self.into())?) + } +} + +// Returns a 1,0 indexed cursor position +fn get_extmark_pos(id: u32, buf: Buffer) -> Result { + let (line, col, _) = buf.get_extmark_by_id( + get_namespace().into(), + id, + &GetExtmarkByIdOpts::builder() + .details(false) + .hl_name(false) + .build(), + )?; + + Ok((line + 1, col).into()) +} + +impl From<&Extmark> for u32 { + fn from(val: &Extmark) -> Self { + val.id + } +} diff --git a/compass/src/common_types/mod.rs b/compass/src/common_types/mod.rs new file mode 100644 index 0000000..fc7dcdb --- /dev/null +++ b/compass/src/common_types/mod.rs @@ -0,0 +1,8 @@ +mod timestamp; +pub use timestamp::*; + +mod cursor_position; +pub use cursor_position::*; + +mod extmark; +pub use extmark::*; diff --git a/compass/src/common_types/timestamp.rs b/compass/src/common_types/timestamp.rs new file mode 100644 index 0000000..3146090 --- /dev/null +++ b/compass/src/common_types/timestamp.rs @@ -0,0 +1,58 @@ +use std::fmt::Display; + +use bitcode::{Decode, Encode}; +use chrono::{DateTime, Utc}; +use serde::de; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Decode, Encode)] +pub struct Timestamp(i64); + +impl<'de> de::Deserialize<'de> for Timestamp { + fn deserialize(deserializer: D) -> std::result::Result + where + D: de::Deserializer<'de>, + { + struct TimestampVisitor; + + impl<'de> de::Visitor<'de> for TimestampVisitor { + type Value = Timestamp; + + fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.write_str("i64 seconds unix epoch timestamp ") + } + + fn visit_i64(self, v: i64) -> Result + where + E: de::Error, + { + Ok(Timestamp(v)) + } + } + + deserializer.deserialize_i64(TimestampVisitor) + } +} + +impl Display for Timestamp { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl From> for Timestamp { + fn from(value: DateTime) -> Self { + Self(value.timestamp()) + } +} + +impl From for i64 { + fn from(value: Timestamp) -> Self { + value.0 + } +} + +impl From for DateTime { + fn from(value: Timestamp) -> Self { + DateTime::from_timestamp(value.into(), 0).unwrap() + } +} diff --git a/compass/src/config.rs b/compass/src/config.rs new file mode 100644 index 0000000..543d3d3 --- /dev/null +++ b/compass/src/config.rs @@ -0,0 +1,44 @@ +pub mod common; +pub use common::*; + +pub mod frecency; +pub use frecency::*; + +pub mod marks; +pub use marks::*; + +pub mod picker; +pub use picker::*; + +pub mod persistence; +pub use persistence::*; + +pub mod tracker; +pub use tracker::*; + +use serde::Deserialize; + +use macros::FromLua; + +static CONFIG: std::sync::OnceLock = std::sync::OnceLock::new(); +pub fn get_config() -> &'static Config { + CONFIG.get_or_init(Config::default) +} +pub fn set_config(conf: Config) { + // We do not care if the cell was initialized + let _ = CONFIG.set(conf); +} + +#[derive(Debug, Deserialize, Default, FromLua)] +#[serde(default)] +pub struct Config { + pub picker: PickerConfig, + + pub tracker: TrackerConfig, + + pub marks: MarksConfig, + + pub persistence: PersistenceConfig, + + pub frecency: FrecencyConfig, +} diff --git a/compass/src/config/common/mod.rs b/compass/src/config/common/mod.rs new file mode 100644 index 0000000..e2b34fe --- /dev/null +++ b/compass/src/config/common/mod.rs @@ -0,0 +1,2 @@ +pub mod sign; +pub use sign::*; diff --git a/compass/src/config/common/sign.rs b/compass/src/config/common/sign.rs new file mode 100644 index 0000000..c74031b --- /dev/null +++ b/compass/src/config/common/sign.rs @@ -0,0 +1,26 @@ +use std::ops::Deref; + +use serde::Deserialize; + +#[derive(Debug, Deserialize)] +pub struct SignText(String); + +impl Default for SignText { + fn default() -> Self { + Self("●".to_owned()) + } +} + +impl From for String { + fn from(value: SignText) -> String { + value.0 + } +} + +impl Deref for SignText { + type Target = str; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} diff --git a/compass/src/config/frecency.rs b/compass/src/config/frecency.rs new file mode 100644 index 0000000..bb7f628 --- /dev/null +++ b/compass/src/config/frecency.rs @@ -0,0 +1,100 @@ +use crate::frecency::FrecencyWeight; + +use serde::Deserialize; + +#[derive(Default, Debug, Deserialize)] +pub struct FrecencyConfig { + #[serde(default)] + pub time_bucket: BucketTimeConfig, + #[serde(default)] + pub visit_type: VisitTypeConfig, + #[serde(default = "default_cooldown_seconds")] + pub cooldown_seconds: i64, +} + +#[derive(Debug, Deserialize)] +pub struct VisitTypeConfig { + #[serde(default = "default_create_visit")] + pub create: FrecencyWeight, + #[serde(default = "default_update_visit")] + pub update: FrecencyWeight, + #[serde(default = "default_relative_goto_visit")] + pub relative_goto: FrecencyWeight, + #[serde(default = "default_absolute_goto_visit")] + pub absolute_goto: FrecencyWeight, +} + +fn default_create_visit() -> FrecencyWeight { + 50.into() +} +fn default_update_visit() -> FrecencyWeight { + 100.into() +} +fn default_relative_goto_visit() -> FrecencyWeight { + 50.into() +} +fn default_absolute_goto_visit() -> FrecencyWeight { + 100.into() +} + +impl Default for VisitTypeConfig { + fn default() -> Self { + Self { + create: default_create_visit(), + update: default_update_visit(), + relative_goto: default_relative_goto_visit(), + absolute_goto: default_absolute_goto_visit(), + } + } +} + +#[derive(Debug, Deserialize)] +pub struct BucketTimeConfig { + #[serde(default = "default_thresholds_bucket")] + pub thresholds: Vec, + #[serde(default = "default_fallback_bucket")] + pub fallback: FrecencyWeight, +} + +fn default_thresholds_bucket() -> Vec { + vec![ + BucketTime { + hours: 4, + weight: 100.into(), + }, + BucketTime { + hours: 14, + weight: 70.into(), + }, + BucketTime { + hours: 31, + weight: 50.into(), + }, + BucketTime { + hours: 90, + weight: 30.into(), + }, + ] +} +fn default_fallback_bucket() -> FrecencyWeight { + 10.into() +} + +impl Default for BucketTimeConfig { + fn default() -> Self { + Self { + thresholds: default_thresholds_bucket(), + fallback: default_fallback_bucket(), + } + } +} + +#[derive(Debug, Deserialize)] +pub struct BucketTime { + pub hours: i64, + pub weight: FrecencyWeight, +} + +fn default_cooldown_seconds() -> i64 { + 60 +} diff --git a/compass/src/config/marks.rs b/compass/src/config/marks.rs new file mode 100644 index 0000000..93a8940 --- /dev/null +++ b/compass/src/config/marks.rs @@ -0,0 +1,15 @@ +mod signs; +pub use signs::*; + +mod update_range; +pub use update_range::*; + +use serde::Deserialize; + +#[derive(Default, Debug, Deserialize)] +pub struct MarksConfig { + #[serde(default)] + pub update_range: UpdateRange, + #[serde(default)] + pub signs: Signs, +} diff --git a/compass/src/config/marks/signs.rs b/compass/src/config/marks/signs.rs new file mode 100644 index 0000000..e07e131 --- /dev/null +++ b/compass/src/config/marks/signs.rs @@ -0,0 +1,16 @@ +use crate::config::SignText; + +use serde::Deserialize; + +#[derive(Debug, Default, Deserialize)] +pub struct Signs { + #[serde(default)] + pub past: SignText, + #[serde(default)] + pub close_past: SignText, + + #[serde(default)] + pub future: SignText, + #[serde(default)] + pub close_future: SignText, +} diff --git a/compass/src/config/marks/update_range.rs b/compass/src/config/marks/update_range.rs new file mode 100644 index 0000000..f04b58d --- /dev/null +++ b/compass/src/config/marks/update_range.rs @@ -0,0 +1,42 @@ +use serde::Deserialize; + +#[derive(Debug, Deserialize)] +pub struct UpdateRange { + #[serde(default = "default_lines")] + pub lines: Option, + #[serde(default = "default_columns")] + pub columns: Option, +} + +#[derive(Debug, Deserialize)] +pub struct Range { + #[serde(default)] + pub single_max_distance: Option, + #[serde(default)] + pub combined_max_distance: Option, +} + +fn default_lines() -> Option { + Range { + single_max_distance: Some(10), + combined_max_distance: Some(25), + } + .into() +} + +fn default_columns() -> Option { + Range { + single_max_distance: None, + combined_max_distance: Some(25), + } + .into() +} + +impl Default for UpdateRange { + fn default() -> Self { + Self { + lines: default_lines(), + columns: default_columns(), + } + } +} diff --git a/compass/src/config/persistence.rs b/compass/src/config/persistence.rs new file mode 100644 index 0000000..bac6126 --- /dev/null +++ b/compass/src/config/persistence.rs @@ -0,0 +1,195 @@ +use crate::Result; +use std::{ + env, fs, + path::{Path, PathBuf}, +}; + +use anyhow::anyhow; +use serde::{de, Deserialize, Serialize}; +use strum::VariantNames; + +#[derive(Debug, Serialize)] +pub struct PersistenceConfig { + #[serde(default = "default_enable")] + pub enable: bool, + #[serde(default)] + pub path: Option, +} + +fn default_enable() -> bool { + true +} + +fn default_path() -> Result { + let dir_path = if cfg!(target_os = "windows") { + env::var("LOCALAPPDATA") + .map(|ev| { + let path: PathBuf = ev.into(); + path.join("nvim-data") + }) + .map_err(|e| anyhow!("{e}")) + } else if let Ok(ev) = env::var("XDG_DATA_HOME") { + let path: PathBuf = ev.into(); + Ok(path.join("nvim")) + } else { + env::var("HOME") + .map(|ev| { + let path: PathBuf = ev.into(); + path.join(".local/share/nvim") + }) + .map_err(|e| anyhow!("{e}")) + }? + .join("compass"); + + get_storage_file_path(dir_path) +} + +fn get_storage_file_path(path: PathBuf) -> Result { + if !path.try_exists().map_err(|e| anyhow!("{e}"))? { + fs::create_dir_all(&path).map_err(|e| anyhow!("{e}"))?; + }; + + let esc_path = escaped_path(&env::current_dir().map_err(|e| anyhow!("{e}"))?)?; + + Ok(path.join(esc_path)) +} + +fn escaped_path(path: &Path) -> Result { + let cwd = path + .as_os_str() + .to_str() + .ok_or_else(|| anyhow!("failed to convert os path to utf-8"))? + .to_owned(); + + Ok(match cfg!(target_os = "windows") { + true => cwd.replace("\\", "_"), + false => cwd.replace("/", "_"), + }) +} + +#[derive(Deserialize, strum_macros::VariantNames)] +#[strum(serialize_all = "lowercase")] +#[serde(field_identifier, rename_all = "lowercase")] +enum PersistenceField { + Enable, + Path, +} + +impl<'de> de::Deserialize<'de> for PersistenceConfig { + fn deserialize(deserializer: D) -> std::result::Result + where + D: de::Deserializer<'de>, + { + struct PersistenceVisitor; + + impl<'de> de::Visitor<'de> for PersistenceVisitor { + type Value = PersistenceConfig; + + fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.write_str("a persistent compass.nvim storage config") + } + + fn visit_map(self, mut map: V) -> std::result::Result + where + V: de::MapAccess<'de>, + { + let mut enable = None; + let mut path = None; + while let Some(key) = map.next_key()? { + match key { + PersistenceField::Enable => { + if enable.is_some() { + return Err(de::Error::duplicate_field("enable")); + } + enable = Some(map.next_value()?); + } + PersistenceField::Path => { + if path.is_some() { + return Err(de::Error::duplicate_field("path")); + } + let base_path: PathBuf = map.next_value()?; + let maybe_full_path = get_storage_file_path(base_path); + match maybe_full_path { + Ok(p) => { + path = Some(p); + } + Err(e) => return Err(de::Error::custom(e)), + }; + } + } + } + + let enable = enable.unwrap_or_else(default_enable); + if enable { + path = match path { + Some(p) => Some(p), + None => match default_path() { + Ok(p) => Some(p), + Err(e) => return Err(de::Error::custom(e)), + }, + } + } + + Ok(PersistenceConfig { enable, path }) + } + } + + deserializer.deserialize_struct( + "PersistenceConfig", + PersistenceField::VARIANTS, + PersistenceVisitor, + ) + } +} + +impl Default for PersistenceConfig { + fn default() -> Self { + Self { + enable: true, + path: match default_path() { + Ok(p) => Some(p), + Err(_) => None, + }, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[cfg(unix)] + #[test] + fn correct_xdg_default_path_on_unix() { + let mut path = PathBuf::from(env::var("HOME").unwrap()); + path.push(".local/share"); + // SAFETY: should be fine in the test env + unsafe { env::set_var("XDG_DATA_HOME", path) }; + + let got = default_path().unwrap(); + + assert!(got.to_str().unwrap().contains("/.local/share/nvim/compass")); + } + + #[cfg(unix)] + #[test] + fn correct_home_default_path_on_unix() { + // SAFETY: should be fine in the test env + unsafe { env::remove_var("XDG_DATA_HOME") }; + + let got = default_path().unwrap(); + + assert!(got.to_str().unwrap().contains("/.local/share/nvim/compass")); + } + + #[cfg(target_os = "windows")] + #[test] + fn correct_default_path_on_windows() { + let got = default_path().unwrap(); + + assert!(got + .to_str() + .unwrap() + .contains("\\AppData\\Local\\nvim-data\\compass")); + } +} diff --git a/compass/src/config/picker.rs b/compass/src/config/picker.rs new file mode 100644 index 0000000..acd49bc --- /dev/null +++ b/compass/src/config/picker.rs @@ -0,0 +1,122 @@ +mod window_grid_size; +pub use window_grid_size::*; + +mod filename; +pub use filename::*; + +mod jump_keymaps; +pub use jump_keymaps::*; + +use serde::de; + +#[derive(Debug, Default)] +pub struct PickerConfig { + pub max_windows: WindowGridSize, + pub jump_keys: JumpKeymapList, + pub filename: Filename, +} + +impl<'de> de::Deserialize<'de> for PickerConfig { + fn deserialize(deserializer: D) -> Result + where + D: de::Deserializer<'de>, + { + enum Field { + MaxWindows, + JumpKeys, + Filename, + } + impl<'de> de::Deserialize<'de> for Field { + fn deserialize(deserializer: D) -> Result + where + D: de::Deserializer<'de>, + { + struct FieldVisitor; + + impl<'de> de::Visitor<'de> for FieldVisitor { + type Value = Field; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("`max_windows`, `jump_keys`, or `filename`") + } + + fn visit_str(self, value: &str) -> Result + where + E: de::Error, + { + match value { + "max_windows" => Ok(Field::MaxWindows), + "jump_keys" => Ok(Field::JumpKeys), + "filename" => Ok(Field::Filename), + _ => Err(de::Error::unknown_field(value, FIELDS)), + } + } + } + + deserializer.deserialize_identifier(FieldVisitor) + } + } + + struct PickerConfigVisitor; + + impl<'de> de::Visitor<'de> for PickerConfigVisitor { + type Value = PickerConfig; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("struct PickerConfig") + } + + fn visit_map(self, mut map: V) -> Result + where + V: de::MapAccess<'de>, + { + let mut max_windows = None; + let mut jump_keys = None; + let mut filename = None; + + while let Some(key) = map.next_key()? { + match key { + Field::MaxWindows => { + if max_windows.is_some() { + return Err(de::Error::duplicate_field("max_windows")); + } + max_windows = Some(map.next_value()?); + } + Field::JumpKeys => { + if jump_keys.is_some() { + return Err(de::Error::duplicate_field("jump_keys")); + } + jump_keys = Some(map.next_value()?); + } + Field::Filename => { + if filename.is_some() { + return Err(de::Error::duplicate_field("filename")); + } + filename = Some(map.next_value()?); + } + } + } + + let max_windows: WindowGridSize = max_windows.unwrap_or_default(); + let jump_keys: JumpKeymapList = jump_keys.unwrap_or_default(); + if Into::::into(max_windows) > jump_keys.len() { + return Err(de::Error::invalid_length( + jump_keys.len(), + &"length of jump_keys must be equal or bigger that the max_windows value", + )); + }; + + let filename = filename.unwrap_or_default(); + + Ok(PickerConfig { + max_windows, + jump_keys, + filename, + }) + } + } + + const FIELDS: &[&str] = &["max_windows", "jump_keys", "filename"]; + deserializer.deserialize_struct("PickerConfig", FIELDS, PickerConfigVisitor) + } +} diff --git a/compass/src/config/picker/filename.rs b/compass/src/config/picker/filename.rs new file mode 100644 index 0000000..c2324d7 --- /dev/null +++ b/compass/src/config/picker/filename.rs @@ -0,0 +1,26 @@ +use serde::Deserialize; + +#[derive(Debug, Deserialize)] +pub struct Filename { + #[serde(default = "default_enable")] + pub enable: bool, + #[serde(default = "default_depth")] + pub depth: usize, +} + +fn default_enable() -> bool { + true +} + +fn default_depth() -> usize { + 2 +} + +impl Default for Filename { + fn default() -> Self { + Self { + enable: default_enable(), + depth: default_depth(), + } + } +} diff --git a/compass/src/config/picker/jump_keymaps.rs b/compass/src/config/picker/jump_keymaps.rs new file mode 100644 index 0000000..dfad776 --- /dev/null +++ b/compass/src/config/picker/jump_keymaps.rs @@ -0,0 +1,76 @@ +use std::slice::Iter; + +use serde::{de, Deserialize}; + +#[derive(Debug, Deserialize)] +pub struct JumpKeymapList(Vec); + +#[derive(Debug, PartialEq, Eq)] +pub struct JumpKeymap { + pub follow: String, + pub immediate: String, +} + +impl<'de> de::Deserialize<'de> for JumpKeymap { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + struct JumpKeymapVisitor; + + impl<'de> de::Visitor<'de> for JumpKeymapVisitor { + type Value = JumpKeymap; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter + .write_str("a tuple consisting of two strings where the first one is a keymap to follow the preview to see more options and the second is to jump straight away to the place highlighted in the preview") + } + + fn visit_seq(self, mut seq: A) -> Result + where + A: serde::de::SeqAccess<'de>, + { + let follow = seq + .next_element()? + .ok_or_else(|| de::Error::invalid_length(0, &self))?; + let immediate = seq + .next_element()? + .ok_or_else(|| de::Error::invalid_length(1, &self))?; + + Ok(JumpKeymap { immediate, follow }) + } + } + + deserializer.deserialize_tuple(2, JumpKeymapVisitor) + } +} + +impl JumpKeymapList { + pub fn get(&self, idx: usize) -> Option<&JumpKeymap> { + self.0.get(idx) + } + + pub fn iter(&self) -> Iter { + self.0.iter() + } + + pub fn len(&self) -> usize { + self.0.len() + } +} + +impl Default for JumpKeymapList { + fn default() -> Self { + let follow = ["j", "f", "k", "d", "l", "s", ";", "a", "h", "g"]; + + Self( + follow + .iter() + .map(|&ik| JumpKeymap { + follow: ik.to_owned(), + immediate: ik.to_owned().to_uppercase(), + }) + .collect::>(), + ) + } +} diff --git a/compass/src/config/picker/window_grid_size.rs b/compass/src/config/picker/window_grid_size.rs new file mode 100644 index 0000000..4b33f10 --- /dev/null +++ b/compass/src/config/picker/window_grid_size.rs @@ -0,0 +1,116 @@ +use crate::{Error, InputError, Result}; + +use anyhow::anyhow; +use serde::de; + +#[derive(Debug, Clone, Copy)] +pub struct WindowGridSize { + half: u32, +} + +impl Default for WindowGridSize { + fn default() -> Self { + Self { half: 3 } + } +} + +impl<'de> de::Deserialize<'de> for WindowGridSize { + fn deserialize(deserializer: D) -> std::result::Result + where + D: de::Deserializer<'de>, + { + struct WindowGridSizeVisitor; + + impl<'de> de::Visitor<'de> for WindowGridSizeVisitor { + type Value = WindowGridSize; + + fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.write_str("an even natural number bigger than 0") + } + + fn visit_i64(self, n: i64) -> std::result::Result + where + E: de::Error, + { + match n.try_into() { + Ok(n) => Ok(n), + _ => Err(E::invalid_value( + de::Unexpected::Signed(n), + &"an even natural number bigger than 0", + )), + } + } + } + + deserializer.deserialize_i64(WindowGridSizeVisitor) + } +} + +impl TryFrom for WindowGridSize { + type Error = Error; + + fn try_from(num: i32) -> Result { + let num: u32 = num.try_into().map_err(|e| anyhow!("{e}"))?; + if num % 2 != 0 { + return Err(InputError::FunctionArguments(format!("provided i32 number: {} is not an even number, but it should be for the window grid size", num)))?; + } + if num == 0 { + return Err(InputError::FunctionArguments( + "provided i32 number is 0 which is invalid for window grid size".to_owned(), + ))?; + } + + Ok(WindowGridSize { half: num / 2 }) + } +} + +impl TryFrom for WindowGridSize { + type Error = Error; + + fn try_from(num: i64) -> Result { + let num: u32 = num.try_into().map_err(|e| anyhow!("{e}"))?; + if num % 2 != 0 { + return Err(InputError::FunctionArguments(format!("provided i64 number: {} is not an even number, but it should be for the window grid size", num)))?; + } + if num == 0 { + return Err(InputError::FunctionArguments( + "provided i64 number is 0 which is invalid for window grid size".to_owned(), + ))?; + } + + Ok(WindowGridSize { half: num / 2 }) + } +} + +impl From for i32 { + fn from(val: WindowGridSize) -> Self { + (val.half * 2).try_into().unwrap() + } +} + +impl From for u32 { + fn from(val: WindowGridSize) -> Self { + val.half * 2 + } +} + +impl From for usize { + fn from(val: WindowGridSize) -> Self { + (val.half * 2).try_into().unwrap() + } +} + +impl<'a> TryFrom<&'a str> for WindowGridSize { + type Error = Error; + + fn try_from(value: &'a str) -> Result { + let i = value.parse::().map_err(|_| { + InputError::FunctionArguments(format!( + "provided a non integer value: {} as window grid size", + value + )) + })?; + + i.try_into() + } +} diff --git a/compass/src/config/tracker.rs b/compass/src/config/tracker.rs new file mode 100644 index 0000000..804b1cd --- /dev/null +++ b/compass/src/config/tracker.rs @@ -0,0 +1,19 @@ +use serde::Deserialize; + +#[derive(Debug, Deserialize)] +pub struct TrackerConfig { + #[serde(default = "default_enable")] + pub enable: bool, +} + +fn default_enable() -> bool { + true +} + +impl Default for TrackerConfig { + fn default() -> Self { + Self { + enable: default_enable(), + } + } +} diff --git a/compass/src/error.rs b/compass/src/error.rs new file mode 100644 index 0000000..8c73aee --- /dev/null +++ b/compass/src/error.rs @@ -0,0 +1,48 @@ +use thiserror::Error; + +pub type Result = std::result::Result; + +#[derive(Error, Debug)] +pub enum Error { + #[error("invalid input provided: {0}")] + Input(#[from] InputError), + + #[error("nvim api error: {0}")] + Api(#[from] nvim_oxi::api::Error), + + #[error("internal compass.nvim error: {0}")] + Internal(#[from] anyhow::Error), +} + +#[derive(Error, Debug)] +pub enum InputError { + #[error("invalid function arguments: {0}")] + FunctionArguments(String), + + #[error("could not parse json-like input: {0}")] + Json(#[from] serde_json::Error), + + #[error("invalid viml: {0}")] + Viml(#[from] VimlError), + + #[error("invalid enum variant provided: {0}")] + EnumParse(#[from] strum::ParseError), + + #[error("provided string can't be parsed to an integer: {0}")] + Int(#[from] std::num::ParseIntError), + + #[error("provided string can't be parsed to a bool: {0}")] + Bool(#[from] std::str::ParseBoolError), + + #[error("no records satistying the action were found: {0}")] + NoRecords(String), + + #[error("{0}")] + Other(String), +} + +#[derive(Error, Debug)] +pub enum VimlError { + #[error("invalid user command: {0}")] + InvalidCommand(String), +} diff --git a/compass/src/frecency/frecency_record.rs b/compass/src/frecency/frecency_record.rs new file mode 100644 index 0000000..b50b668 --- /dev/null +++ b/compass/src/frecency/frecency_record.rs @@ -0,0 +1,32 @@ +use super::{FrecencyType, FrecencyWeight}; +use crate::common_types::Timestamp; + +use bitcode::{Decode, Encode}; +use chrono::{DateTime, Utc}; + +#[derive(Clone, Debug, PartialEq, Eq, Hash, Decode, Encode)] +pub struct FrecencyRecord { + pub timestamp: Timestamp, + typ: FrecencyType, +} + +impl FrecencyRecord { + pub fn new(typ: FrecencyType) -> Self { + Self { + timestamp: Utc::now().into(), + typ, + } + } + + pub fn score(&self) -> FrecencyWeight { + let days = { + let time = DateTime::from_timestamp(self.timestamp.into(), 0).unwrap(); + let dur = time.signed_duration_since(Utc::now()); + FrecencyWeight::from(dur) + }; + + let typ = self.typ.weight(); + + days + typ + } +} diff --git a/compass/src/frecency/frecency_type.rs b/compass/src/frecency/frecency_type.rs new file mode 100644 index 0000000..b5af266 --- /dev/null +++ b/compass/src/frecency/frecency_type.rs @@ -0,0 +1,25 @@ +use super::FrecencyWeight; +use crate::config::get_config; + +use bitcode::{Decode, Encode}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Decode, Encode)] +pub enum FrecencyType { + Create, + Update, + RelativeGoto, + AbsoluteGoto, +} + +impl FrecencyType { + pub fn weight(self) -> FrecencyWeight { + let conf = &get_config().frecency.visit_type; + + match self { + Self::Create => conf.create, + Self::Update => conf.update, + Self::RelativeGoto => conf.relative_goto, + Self::AbsoluteGoto => conf.absolute_goto, + } + } +} diff --git a/compass/src/frecency/frecency_weight.rs b/compass/src/frecency/frecency_weight.rs new file mode 100644 index 0000000..0669753 --- /dev/null +++ b/compass/src/frecency/frecency_weight.rs @@ -0,0 +1,35 @@ +use crate::config::get_config; +use std::ops::Add; + +use serde::Deserialize; + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Copy, Deserialize)] +#[serde(transparent)] +pub struct FrecencyWeight(i64); + +impl Add for FrecencyWeight { + type Output = Self; + + fn add(self, rhs: Self) -> Self::Output { + Self(self.0 + rhs.0) + } +} + +impl From for FrecencyWeight { + fn from(value: i64) -> Self { + Self(value) + } +} + +impl From for FrecencyWeight { + fn from(value: chrono::Duration) -> Self { + let conf = &get_config().frecency.time_bucket; + + let hours = value.num_hours(); + conf.thresholds + .iter() + .find(|b| b.hours > hours) + .map(|b| b.weight) + .unwrap_or_else(|| conf.fallback) + } +} diff --git a/compass/src/frecency/mod.rs b/compass/src/frecency/mod.rs new file mode 100644 index 0000000..e8f3ba3 --- /dev/null +++ b/compass/src/frecency/mod.rs @@ -0,0 +1,52 @@ +mod frecency_record; +use frecency_record::*; + +mod frecency_type; +pub use frecency_type::FrecencyType; + +mod frecency_weight; +pub use frecency_weight::FrecencyWeight; + +use crate::{common_types::Timestamp, config::get_config}; + +use bitcode::{Decode, Encode}; +use chrono::{DateTime, Utc}; + +pub trait FrecencyScore { + fn total_score(&self) -> FrecencyWeight; +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Decode, Encode)] +pub struct Frecency { + vec: Vec, +} + +impl Frecency { + pub fn new() -> Self { + Self { + vec: Vec::from([FrecencyRecord::new(FrecencyType::Create)]), + } + } + + pub fn add_record(&mut self, typ: FrecencyType) { + if self.vec.last().is_some_and(|r| { + Utc::now().signed_duration_since(Into::>::into(r.timestamp)) + > chrono::Duration::seconds(get_config().frecency.cooldown_seconds) + }) { + self.vec.push(FrecencyRecord::new(typ)); + } + } + + pub fn latest_timestamp(&self) -> Timestamp { + // Should never panic since there must always be at least a single record + self.vec.last().unwrap().timestamp + } +} + +impl FrecencyScore for Frecency { + fn total_score(&self) -> FrecencyWeight { + self.vec + .iter() + .fold(Into::::into(0), |acc, r| r.score() + acc) + } +} diff --git a/compass/src/functions/follow/completion.rs b/compass/src/functions/follow/completion.rs new file mode 100644 index 0000000..be65a53 --- /dev/null +++ b/compass/src/functions/follow/completion.rs @@ -0,0 +1,13 @@ +use crate::viml::CompassArgs; + +pub fn get_follow_completion(cargs: &CompassArgs) -> Vec { + let Some(first) = cargs.sub_cmds.first() else { + return Vec::from(&["buf".to_owned()]); + }; + + match *first { + "buf" => Vec::from(&["target=".to_owned(), "max_windows=".to_owned()]), + + _ => Vec::from(&["buf".to_owned()]), + } +} diff --git a/compass/src/functions/follow/mod.rs b/compass/src/functions/follow/mod.rs new file mode 100644 index 0000000..91e04cf --- /dev/null +++ b/compass/src/functions/follow/mod.rs @@ -0,0 +1,154 @@ +mod completion; +pub use completion::*; + +mod opts; +use opts::*; + +use crate::{ + config::{get_config, JumpKeymap, WindowGridSize}, + frecency::FrecencyType, + functions::open::get_unique_bufs_priority, + state::{Record, SyncTracker, TrackList}, + ui::grid::{open_grid, GridLayout}, + Result, +}; + +use anyhow::anyhow; +use nvim_oxi::api::{get_current_win, Buffer}; + +pub fn get_follow(sync_tracker: SyncTracker) -> impl Fn(Option) -> Result<()> { + move |opts: Option| { + let opts = opts.unwrap_or_default(); + + let mut tracker = sync_tracker.lock()?; + + match opts { + FollowOptions::Buf(BufOptions { + target, + max_windows, + }) => { + { + let mut records_iter = tracker + .list + .iter_mut_from_future() + .filter(|r| r.buf == target); + + if let Some(only) = records_iter.next() { + if records_iter.next().is_none() { + return only.goto(get_current_win(), FrecencyType::AbsoluteGoto); + }; + }; + }; + + let (record_vec, jump_keymaps) = follow_buf(target, &tracker.list, max_windows)?; + + open_grid( + &record_vec, + max_windows, + GridLayout::Follow, + jump_keymaps.into_iter(), + )?; + + Ok(()) + } + } + } +} + +fn follow_buf( + target: Buffer, + record_list: &TrackList, + max_windows: WindowGridSize, +) -> Result<(Vec<&Record>, Vec<&JumpKeymap>)> { + let mut record_vec = Vec::<&Record>::new(); + let mut reused_keymap_vec: Vec = Vec::new(); + + for (i, r) in get_unique_bufs_priority(max_windows, record_list)? + .iter() + .enumerate() + { + if record_vec.len() >= max_windows.into() { + break; + }; + + if r.buf == target { + record_vec.push(r); + reused_keymap_vec.push(i); + } + } + + let jump_keymaps = { + let mut jump_vec: Vec<&JumpKeymap> = Vec::new(); + for &i in reused_keymap_vec.iter() { + let v = match get_config().picker.jump_keys.get(i) { + Some(k) => k, + None => get_config() + .picker + .jump_keys + .iter() + .find(|k| !jump_vec.contains(k)) + .ok_or_else(|| anyhow!("unexpectedly there is not enough jump keys to be used for follow command"))?, + }; + + jump_vec.push(v); + } + + jump_vec + }; + + Ok((record_vec, jump_keymaps)) +} + +mod tests { + use crate::state::{ChangeTypeRecord, TypeRecord}; + + use super::*; + + use nvim_oxi::api::create_buf; + + #[nvim_oxi::test] + fn can_reuse_keymap_with_follow_buf() { + let buf1 = create_buf(true, false).unwrap(); + let buf2 = create_buf(true, false).unwrap(); + let mut record_list: TrackList = TrackList::default(); + record_list.push( + Record::try_new( + buf1.clone(), + TypeRecord::Change(ChangeTypeRecord::Tick(3.into())), + &(3, 3).into(), + ) + .unwrap(), + ); // k + record_list.push( + Record::try_new( + buf2, + TypeRecord::Change(ChangeTypeRecord::Tick(2.into())), + &(2, 2).into(), + ) + .unwrap(), + ); // f + record_list.push( + Record::try_new( + buf1.clone(), + TypeRecord::Change(ChangeTypeRecord::Tick(1.into())), + &(1, 1).into(), + ) + .unwrap(), + ); // j + + let (record_vec, jump_keymaps) = + follow_buf(buf1, &record_list, WindowGridSize::default()).unwrap(); + + assert_eq!( + record_vec.first().unwrap().typ, + TypeRecord::Change(ChangeTypeRecord::Tick(1.into())) + ); + assert_eq!( + record_vec.get(1).unwrap().typ, + TypeRecord::Change(ChangeTypeRecord::Tick(3.into())) + ); + let def_keymaps = &get_config().picker.jump_keys; + assert_eq!(*jump_keymaps.first().unwrap(), def_keymaps.get(0).unwrap()); // j + assert_eq!(*jump_keymaps.get(1).unwrap(), def_keymaps.get(2).unwrap()); // k + } +} diff --git a/compass/src/functions/follow/opts.rs b/compass/src/functions/follow/opts.rs new file mode 100644 index 0000000..eeb3d11 --- /dev/null +++ b/compass/src/functions/follow/opts.rs @@ -0,0 +1,83 @@ +use crate::{ + config::{get_config, WindowGridSize}, + viml::CompassArgs, + Error, InputError, Result, +}; +use macros::FromLua; + +use nvim_oxi::api::{get_current_buf, Buffer}; +use serde::Deserialize; + +#[derive(Deserialize, FromLua)] +pub enum FollowOptions { + Buf(BufOptions), +} + +impl Default for FollowOptions { + fn default() -> Self { + Self::Buf(BufOptions::default()) + } +} + +#[derive(Deserialize)] +pub struct BufOptions { + #[serde(default = "get_current_buf")] + pub target: Buffer, + #[serde(default = "default_max_windows")] + pub max_windows: WindowGridSize, +} + +fn default_max_windows() -> WindowGridSize { + get_config().picker.max_windows +} + +impl Default for BufOptions { + fn default() -> Self { + Self { + target: get_current_buf(), + max_windows: default_max_windows(), + } + } +} + +impl<'a> TryFrom> for FollowOptions { + type Error = Error; + + fn try_from(value: CompassArgs<'a>) -> Result { + let Some(&sub) = value.sub_cmds.first() else { + Err(InputError::FunctionArguments( + "no `follow` subcommand provided".to_owned(), + ))? + }; + + match sub { + "buf" => { + let target = value + .map_args + .get("target") + .map(|&s| s.parse::()) + .transpose() + .map_err(InputError::Int)? + .map(Into::into) + .unwrap_or_else(get_current_buf); + + let max_windows = value + .map_args + .get("max_windows") + .map(|&s| s.try_into()) + .transpose()? + .unwrap_or_default(); + + Ok(Self::Buf(BufOptions { + target, + max_windows, + })) + } + + sub => Err(InputError::FunctionArguments(format!( + "unknown `follow` subcommand provided: {}", + sub + )))?, + } + } +} diff --git a/compass/src/functions/goto/completion.rs b/compass/src/functions/goto/completion.rs new file mode 100644 index 0000000..210136f --- /dev/null +++ b/compass/src/functions/goto/completion.rs @@ -0,0 +1,14 @@ +use crate::viml::CompassArgs; + +pub fn get_goto_completion(cargs: &CompassArgs) -> Vec { + let Some(first) = cargs.sub_cmds.first() else { + return Vec::from(&["relative".to_owned(), "absolute".to_owned()]); + }; + + match *first { + "relative" => Vec::from(&["direction=".to_owned()]), + "absolute" => Vec::from(&["target=".to_owned()]), + + _ => Vec::from(&["relative".to_owned(), "absolute".to_owned()]), + } +} diff --git a/compass/src/functions/goto/mod.rs b/compass/src/functions/goto/mod.rs new file mode 100644 index 0000000..e0b60f6 --- /dev/null +++ b/compass/src/functions/goto/mod.rs @@ -0,0 +1,82 @@ +mod completion; +pub use completion::*; + +mod opts; +use opts::*; + +use crate::{frecency::FrecencyType, state::SyncTracker, InputError, Result}; + +use nvim_oxi::api::get_current_win; + +pub fn get_goto(tracker: SyncTracker) -> impl Fn(Option) -> Result<()> { + move |opts: Option| { + let opts = opts.unwrap_or_default(); + + let mut tracker = tracker.lock()?; + + match opts { + GotoOptions::Relative(RelativeOptions { direction }) => { + let record = match direction { + Direction::Back => tracker.list.step_past_mut().ok_or_else(|| { + InputError::NoRecords("no more records to go back to".to_owned()) + })?, + Direction::Forward => tracker.list.step_future_mut().ok_or_else(|| { + InputError::NoRecords( + "no more records ahead of the current point".to_owned(), + ) + })?, + }; + + let win = get_current_win(); + record.goto(win, FrecencyType::RelativeGoto)?; + } + + GotoOptions::Absolute(AbsoluteOptions { + target: AbsoluteTarget::Index(idx_record), + }) => { + let win = get_current_win(); + + let record = tracker.list.get_mut(idx_record).ok_or_else(|| { + InputError::NoRecords(format!( + "non-existent index for absolute goto provided: {}", + idx_record + )) + })?; + + record.goto(win, FrecencyType::AbsoluteGoto)?; + } + + GotoOptions::Absolute(AbsoluteOptions { + target: AbsoluteTarget::Time(t), + }) => { + let win = get_current_win(); + + let record = tracker + .list + .iter_mut_from_future() + .find(|r| r.buf == t.buf && r.frecency.latest_timestamp() == t.timestamp) + .ok_or_else(|| InputError::NoRecords("no such record identified".to_owned()))?; + + record.goto(win, FrecencyType::AbsoluteGoto)?; + } + + GotoOptions::Absolute(AbsoluteOptions { + target: AbsoluteTarget::Tick(t), + }) => { + let win = get_current_win(); + + let record = tracker + .list + .iter_mut_from_future() + .find(|r| { + r.buf == t.buf && r.typ.tick().is_some_and(|rec_tick| rec_tick == t.tick) + }) + .ok_or_else(|| InputError::NoRecords("no such record identified".to_owned()))?; + + record.goto(win, FrecencyType::AbsoluteGoto)?; + } + } + + Ok(()) + } +} diff --git a/compass/src/functions/goto/opts.rs b/compass/src/functions/goto/opts.rs new file mode 100644 index 0000000..38cbbf7 --- /dev/null +++ b/compass/src/functions/goto/opts.rs @@ -0,0 +1,174 @@ +use crate::{ + common_types::Timestamp, frecency::FrecencyType, state::Tick, viml::CompassArgs, Error, + InputError, Result, +}; +use macros::FromLua; + +use nvim_oxi::api::Buffer; +use serde::Deserialize; +use strum_macros::EnumString; + +#[derive(Debug, Deserialize, FromLua)] +#[serde(rename_all = "lowercase")] +pub enum GotoOptions { + Relative(RelativeOptions), + Absolute(AbsoluteOptions), +} + +impl Default for GotoOptions { + fn default() -> Self { + Self::Relative(RelativeOptions::default()) + } +} + +#[derive(Debug, Deserialize)] +pub struct RelativeOptions { + pub direction: Direction, +} + +impl Default for RelativeOptions { + fn default() -> Self { + Self { + direction: Direction::Back, + } + } +} + +#[derive(Debug, Deserialize, EnumString)] +#[serde(rename_all = "lowercase")] +#[strum(serialize_all = "lowercase")] +pub enum Direction { + Back, + Forward, +} + +#[derive(Debug, Deserialize)] +pub enum AbsoluteTarget { + Time(TimeTarget), + Tick(TickTarget), + Index(usize), +} + +#[derive(Debug, Deserialize)] +pub struct TickTarget { + pub buf: Buffer, + pub tick: Tick, +} + +#[derive(Debug, Deserialize)] +pub struct TimeTarget { + pub buf: Buffer, + pub timestamp: Timestamp, +} + +#[derive(Debug, Deserialize)] +pub struct AbsoluteOptions { + pub target: AbsoluteTarget, +} + +impl<'a> TryFrom> for GotoOptions { + type Error = Error; + + fn try_from(value: CompassArgs) -> Result { + let Some(&sub) = value.sub_cmds.first() else { + Err(InputError::FunctionArguments( + "no `goto` subcommand provided".to_owned(), + ))? + }; + + match sub { + "relative" => { + let direction: Direction = value + .map_args + .get("direction") + .copied() + .ok_or_else(|| { + InputError::FunctionArguments( + "have chosen `relative` but not specifed the direction".to_owned(), + ) + })? + .try_into() + .map_err(InputError::EnumParse)?; + + Ok(Self::Relative(RelativeOptions { direction })) + } + "absolute" => { + if let Some(str_tick) = value.map_args.get("tick").copied() { + let target_tick: TickTarget = + serde_json::from_str(str_tick).map_err(InputError::Json)?; + return Ok(Self::Absolute(AbsoluteOptions { + target: AbsoluteTarget::Tick(target_tick), + })); + } + + if let Some(str_time) = value.map_args.get("time").copied() { + let target_time: TimeTarget = + serde_json::from_str(str_time).map_err(InputError::Json)?; + return Ok(Self::Absolute(AbsoluteOptions { + target: AbsoluteTarget::Time(target_time), + })); + } + + if let Some(index_str) = value.map_args.get("index").copied() { + let index: usize = index_str.parse().map_err(InputError::Int)?; + return Ok(Self::Absolute(AbsoluteOptions { + target: AbsoluteTarget::Index(index), + })); + }; + + Err(InputError::FunctionArguments( + "have chosen `absolute` but did not provide coordinates in a valid format" + .to_owned(), + ))? + } + + sub => Err(InputError::FunctionArguments(format!( + "unknown `goto` subcommand provided: {}", + sub + )))?, + } + } +} + +impl From for FrecencyType { + fn from(value: GotoOptions) -> Self { + match value { + GotoOptions::Relative(_) => Self::RelativeGoto, + GotoOptions::Absolute(_) => Self::AbsoluteGoto, + } + } +} + +#[cfg(test)] +mod tests { + use core::panic; + use std::collections::HashMap; + + use super::*; + + #[test] + fn can_turn_compass_args_object() { + let mut map_args: HashMap<&str, &str> = HashMap::new(); + map_args.insert("tick", r#"{"buf":153,"tick":42}"#); + + let args = CompassArgs { + main_cmd: "goto", + sub_cmds: vec!["absolute"], + map_args, + }; + + let got: GotoOptions = args.try_into().unwrap(); + + match got { + GotoOptions::Absolute(AbsoluteOptions { target }) => match target { + AbsoluteTarget::Tick(TickTarget { tick, .. }) => { + assert_eq!(tick, 42.into()); + } + + _ => panic!("got: {:?}", target), + }, + + _ => panic!("got: {:?}", got), + } + } +} diff --git a/compass/src/functions/mod.rs b/compass/src/functions/mod.rs new file mode 100644 index 0000000..1afca8b --- /dev/null +++ b/compass/src/functions/mod.rs @@ -0,0 +1,17 @@ +//! Plugin's functions exposed to lua +//! Uses a macro to generate enums for each submodule +//! `CommandNames` enum represents functions that need a user command to be generated for them + +pub mod setup; + +pub mod goto; + +pub mod open; + +pub mod place; + +pub mod follow; + +pub mod pop; + +macros::functions_and_commands!("./src/functions"); diff --git a/compass/src/functions/open/completion.rs b/compass/src/functions/open/completion.rs new file mode 100644 index 0000000..ace838b --- /dev/null +++ b/compass/src/functions/open/completion.rs @@ -0,0 +1,3 @@ +pub fn get_open_completion() -> Vec { + Vec::from(&["record_types=".to_owned(), "max_windows=".to_owned()]) +} diff --git a/compass/src/functions/open/mod.rs b/compass/src/functions/open/mod.rs new file mode 100644 index 0000000..49ee5a0 --- /dev/null +++ b/compass/src/functions/open/mod.rs @@ -0,0 +1,191 @@ +pub mod completion; +pub use completion::*; + +mod opts; +use opts::*; + +use crate::{ + common_types::CursorPosition, + config::{get_config, WindowGridSize}, + state::{Record, SyncTracker, TrackList, Tracker}, + ui::{ + ensure_buf_loaded::ensure_buf_loaded, + grid::{open_grid, GridLayout}, + tab::{close_tab, open_tab}, + }, + Result, +}; +use std::collections::HashSet; + +use nvim_oxi::api::Buffer; + +pub fn get_open(tracker: SyncTracker) -> impl Fn(Option) -> Result<()> { + move |opts: Option| { + let OpenOptions { + record_types, + max_windows, + } = opts.unwrap_or_default(); + + let tracker = tracker.lock()?; + + let record_list: Vec<&Record> = { + let iter = get_unique_bufs_priority(max_windows, &tracker.list)?.into_iter(); + match record_types { + Some(record_types) => iter + .filter(|&r| record_types.iter().any(|&f| f == r.typ.into())) + .collect(), + None => iter.collect(), + } + }; + let layout = { + match record_list + .first() + .is_some_and(|f| record_list.iter().all(|r| r.buf == f.buf)) + { + true => GridLayout::Follow, + false => GridLayout::Open, + } + }; + + open_grid( + &record_list, + max_windows, + layout, + get_config().picker.jump_keys.iter(), + )?; + + Ok(()) + } +} + +pub fn get_unique_bufs_priority( + max_windows: WindowGridSize, + track_list: &TrackList, +) -> Result> { + let mut seen_bufs = HashSet::::with_capacity(max_windows.into()); + let mut record_list = Vec::<&Record>::with_capacity(max_windows.into()); + + open_tab()?; + + let frecency_list = track_list.frecency(); + for (i, r) in frecency_list.iter() { + if seen_bufs.insert(r.buf.clone()) { + ensure_buf_loaded(*i, track_list.pos, r)?; + + record_list.push(r); + + if record_list.len() >= max_windows.into() { + break; + } + } + } + if record_list.len() < max_windows.into() { + for (i, r) in frecency_list.iter() { + if !record_list.iter().any(|&col_rec| col_rec == *r) { + ensure_buf_loaded(*i, track_list.pos, r)?; + + record_list.push(r); + } + + if record_list.len() >= max_windows.into() { + break; + } + } + } + + close_tab()?; + + Ok(record_list) +} + +mod tests { + use crate::state::{ChangeTypeRecord, TypeRecord}; + + use super::*; + + use nvim_oxi::api::{create_buf, Buffer}; + + #[nvim_oxi::test] + fn can_open_picker_with_empty_args() { + let sync_tracker = SyncTracker::default(); + let mut tracker = sync_tracker.lock().unwrap(); + tracker.list.push( + Record::try_new( + Buffer::current(), + TypeRecord::Change(ChangeTypeRecord::Tick(42.into())), + &CursorPosition::from((1, 2)), + ) + .unwrap(), + ); + let open = get_open(sync_tracker.clone()); + + drop(tracker); + + open(None).unwrap(); + } + + #[nvim_oxi::test] + fn can_open_picker_with_all_args() { + let sync_tracker = SyncTracker::default(); + let mut tracker = sync_tracker.lock().unwrap(); + tracker.list.push( + Record::try_new( + Buffer::current(), + TypeRecord::Change(ChangeTypeRecord::Tick(42.into())), + &CursorPosition::from((1, 2)), + ) + .unwrap(), + ); + let open = get_open(sync_tracker.clone()); + + drop(tracker); + + open(Some(OpenOptions { + record_types: Some(vec![RecordFilter::Change]), + max_windows: 8.try_into().unwrap(), + })) + .unwrap(); + } + + #[nvim_oxi::test] + fn can_get_unique_bufs() { + let mut tracker = Tracker::default(); + let repeated_buf = create_buf(true, false).unwrap(); + let unique_buf = create_buf(true, false).unwrap(); + let rec1 = Record::try_new( + repeated_buf.clone(), + TypeRecord::Change(ChangeTypeRecord::Tick(4.into())), + &CursorPosition::from((2, 2)), + ) + .unwrap(); + tracker.list.push(rec1); + let rec2 = Record::try_new( + repeated_buf.clone(), + TypeRecord::Change(ChangeTypeRecord::Tick(11.into())), + &CursorPosition::from((4, 6)), + ) + .unwrap(); + tracker.list.push(rec2); + let rec3 = Record::try_new( + unique_buf.clone(), + TypeRecord::Change(ChangeTypeRecord::Tick(15.into())), + &CursorPosition::from((5, 11)), + ) + .unwrap(); + tracker.list.push(rec3.clone()); + let rec4 = Record::try_new( + repeated_buf.clone(), + TypeRecord::Change(ChangeTypeRecord::Tick(16.into())), + &CursorPosition::from((15, 42)), + ) + .unwrap(); + tracker.list.push(rec4.clone()); + + let got = get_unique_bufs_priority(2.try_into().unwrap(), &tracker.list).unwrap(); + let mut iter = got.iter(); + + assert_eq!(got.len(), 2); + assert_eq!(**iter.next().unwrap(), rec4); + assert_eq!(**iter.next().unwrap(), rec3); + } +} diff --git a/compass/src/functions/open/opts.rs b/compass/src/functions/open/opts.rs new file mode 100644 index 0000000..5066724 --- /dev/null +++ b/compass/src/functions/open/opts.rs @@ -0,0 +1,79 @@ +use crate::{ + config::{get_config, WindowGridSize}, + state::TypeRecord, + viml::CompassArgs, + Error, InputError, Result, +}; +use macros::FromLua; + +use serde::Deserialize; + +#[derive(Deserialize, FromLua)] +pub struct OpenOptions { + pub record_types: Option>, + pub max_windows: WindowGridSize, +} + +impl Default for OpenOptions { + fn default() -> Self { + let conf = &get_config().picker; + + Self { + record_types: None, + max_windows: conf.max_windows, + } + } +} + +#[derive(Deserialize, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum RecordFilter { + Change, +} + +impl TryFrom<&str> for RecordFilter { + type Error = Error; + + fn try_from(value: &str) -> Result { + match value { + "change" => Ok(RecordFilter::Change), + _ => Err(InputError::FunctionArguments(format!( + "unkwnown filter provided: {}", + value + )))?, + } + } +} + +impl From for RecordFilter { + fn from(value: TypeRecord) -> Self { + match value { + TypeRecord::Change(_) => Self::Change, + } + } +} + +impl<'a> TryFrom> for OpenOptions { + type Error = Error; + + fn try_from(value: CompassArgs<'a>) -> Result { + let record_types = value + .map_args + .get("record_types") + .map(|s| serde_json::from_str::>(s)) + .transpose() + .map_err(InputError::Json)?; + + let max_windows = value + .map_args + .get("max_windows") + .map(|&s| s.try_into()) + .transpose()? + .unwrap_or_default(); + + Ok(Self { + record_types, + max_windows, + }) + } +} diff --git a/compass/src/functions/place/completion.rs b/compass/src/functions/place/completion.rs new file mode 100644 index 0000000..cea7c08 --- /dev/null +++ b/compass/src/functions/place/completion.rs @@ -0,0 +1,13 @@ +use crate::viml::CompassArgs; + +pub fn get_place_completion(cargs: &CompassArgs) -> Vec { + let Some(first) = cargs.sub_cmds.first() else { + return Vec::from(&["change".to_owned()]); + }; + + match *first { + "change" => Vec::from(&["try_update=".to_owned()]), + + _ => Vec::from(&["change".to_owned()]), + } +} diff --git a/compass/src/functions/place/mod.rs b/compass/src/functions/place/mod.rs new file mode 100644 index 0000000..4c2c68f --- /dev/null +++ b/compass/src/functions/place/mod.rs @@ -0,0 +1,74 @@ +mod completion; +pub use completion::*; + +mod opts; +use opts::*; + +use crate::{ + state::{ChangeTypeRecord, Record, SyncTracker, TrackList, TypeRecord}, + ui::record_mark::RecordMarkTime, + Result, +}; + +use nvim_oxi::api::{get_current_buf, get_current_win, Buffer, Window}; + +pub fn get_place(sync_tracker: SyncTracker) -> impl Fn(Option) -> Result<()> { + move |opts: Option| { + let opts = opts.unwrap_or_default(); + + let mut tracker = sync_tracker.lock()?; + + match opts { + PlaceOptions::Change(ChangeOptions { try_update }) => { + let buf_curr = get_current_buf(); + let win_curr = get_current_win(); + + match try_update { + true => { + let pos_curr = get_current_win().get_cursor()?.into(); + + let Some((i, old_record)) = + tracker.list.iter_mut_from_future().enumerate().find( + |(_, Record { buf, extmark, .. })| { + buf_curr == *buf && { + extmark.get_pos(buf_curr.clone()).is_nearby(&pos_curr) + } + }, + ) + else { + return new_change_manual_record(buf_curr, win_curr, &mut tracker.list); + }; + + old_record.update( + buf_curr, + TypeRecord::Change(ChangeTypeRecord::Manual(old_record.typ.tick())), + &pos_curr, + RecordMarkTime::PastClose, + )?; + tracker.list.make_close_past(i); + + Ok(()) + } + + false => new_change_manual_record(buf_curr, win_curr, &mut tracker.list), + } + } + } + } +} + +fn new_change_manual_record( + buf: Buffer, + win: Window, + record_list: &mut TrackList, +) -> Result<()> { + let record_new = Record::try_new( + buf, + TypeRecord::Change(ChangeTypeRecord::Manual(None)), + &win.get_cursor()?.into(), + )?; + + record_list.push(record_new); + + Ok(()) +} diff --git a/compass/src/functions/place/opts.rs b/compass/src/functions/place/opts.rs new file mode 100644 index 0000000..fae12d1 --- /dev/null +++ b/compass/src/functions/place/opts.rs @@ -0,0 +1,56 @@ +use crate::{viml::CompassArgs, Error, InputError, Result}; +use macros::FromLua; + +use serde::Deserialize; + +#[derive(Deserialize, FromLua)] +pub enum PlaceOptions { + Change(ChangeOptions), +} + +impl Default for PlaceOptions { + fn default() -> Self { + PlaceOptions::Change(ChangeOptions::default()) + } +} + +#[derive(Default, Deserialize)] +pub struct ChangeOptions { + #[serde(default = "default_try_update")] + pub try_update: bool, +} + +fn default_try_update() -> bool { + true +} + +impl<'a> TryFrom> for PlaceOptions { + type Error = Error; + + fn try_from(value: CompassArgs<'a>) -> Result { + let Some(&sub) = value.sub_cmds.first() else { + Err(InputError::FunctionArguments( + "no `place` subcommand provided".to_owned(), + ))? + }; + + match sub { + "change" => { + let try_update = value + .map_args + .get("try_update") + .map(|&s| s.parse::()) + .transpose() + .map_err(InputError::Bool)? + .unwrap_or_else(default_try_update); + + Ok(Self::Change(ChangeOptions { try_update })) + } + + sub => Err(InputError::FunctionArguments(format!( + "unknown `place` subcommand provided: {}", + sub + )))?, + } + } +} diff --git a/compass/src/functions/pop/completion.rs b/compass/src/functions/pop/completion.rs new file mode 100644 index 0000000..ad75d7e --- /dev/null +++ b/compass/src/functions/pop/completion.rs @@ -0,0 +1,13 @@ +use crate::viml::CompassArgs; + +pub fn get_pop_completion(cargs: &CompassArgs) -> Vec { + let Some(first) = cargs.sub_cmds.first() else { + return Vec::from(&["relative".to_owned()]); + }; + + match *first { + "relative" => Vec::from(&["direction=".to_owned()]), + + _ => Vec::from(&["relative".to_owned()]), + } +} diff --git a/compass/src/functions/pop/mod.rs b/compass/src/functions/pop/mod.rs new file mode 100644 index 0000000..1029ebc --- /dev/null +++ b/compass/src/functions/pop/mod.rs @@ -0,0 +1,35 @@ +mod completion; +pub use completion::*; + +mod opts; +use opts::*; + +use crate::{state::SyncTracker, InputError, Result}; + +use nvim_oxi::api::get_current_win; + +pub fn get_pop(tracker: SyncTracker) -> impl Fn(Option) -> Result<()> { + move |opts: Option| { + let opts = opts.unwrap_or_default(); + + let mut tracker = tracker.lock()?; + + match opts { + PopOptions::Relative(RelativeOptions { direction }) => { + let mut record = match direction { + Direction::Back => tracker.list.pop_past().ok_or_else(|| { + InputError::NoRecords("no more previous records to pop".to_owned()) + })?, + Direction::Forward => tracker.list.pop_future().ok_or_else(|| { + InputError::NoRecords("no more next records to pop".to_owned()) + })?, + }; + + let win = get_current_win(); + record.pop(win)?; + } + } + + Ok(()) + } +} diff --git a/compass/src/functions/pop/opts.rs b/compass/src/functions/pop/opts.rs new file mode 100644 index 0000000..8e801b2 --- /dev/null +++ b/compass/src/functions/pop/opts.rs @@ -0,0 +1,73 @@ +use crate::{viml::CompassArgs, Error, InputError, Result}; +use macros::FromLua; + +use serde::Deserialize; +use strum_macros::EnumString; + +#[derive(Debug, Deserialize, FromLua)] +#[serde(rename_all = "lowercase")] +pub enum PopOptions { + Relative(RelativeOptions), +} + +impl Default for PopOptions { + fn default() -> Self { + Self::Relative(RelativeOptions::default()) + } +} + +#[derive(Debug, Deserialize)] +pub struct RelativeOptions { + pub direction: Direction, +} + +impl Default for RelativeOptions { + fn default() -> Self { + Self { + direction: Direction::Back, + } + } +} + +#[derive(Debug, Deserialize, EnumString)] +#[serde(rename_all = "lowercase")] +#[strum(serialize_all = "lowercase")] +pub enum Direction { + Back, + Forward, +} + +impl<'a> TryFrom> for PopOptions { + type Error = Error; + + fn try_from(value: CompassArgs) -> Result { + let Some(&sub) = value.sub_cmds.first() else { + Err(InputError::FunctionArguments( + "no `pop` subcommand provided".to_owned(), + ))? + }; + + match sub { + "relative" => { + let direction: Direction = value + .map_args + .get("direction") + .copied() + .ok_or_else(|| { + InputError::FunctionArguments( + "have chosen `relative` but not specifed the direction".to_owned(), + ) + })? + .try_into() + .map_err(InputError::EnumParse)?; + + Ok(Self::Relative(RelativeOptions { direction })) + } + + sub => Err(InputError::FunctionArguments(format!( + "unknown `pop` subcommand provided: {}", + sub + )))?, + } + } +} diff --git a/compass/src/functions/setup.rs b/compass/src/functions/setup.rs new file mode 100644 index 0000000..4f9e8c4 --- /dev/null +++ b/compass/src/functions/setup.rs @@ -0,0 +1,34 @@ +use crate::{ + config::{get_config, set_config, Config}, + state::{SyncTracker, Worker}, + ui::highlights::{apply_highlights, HighlightList}, + Result, +}; + +use anyhow::anyhow; + +pub fn get_setup(mut tracker: SyncTracker) -> impl FnOnce(Option) -> Result<()> { + |user_conf: Option| { + set_config(user_conf.unwrap_or_default()); + + let conf = get_config(); + + if conf.persistence.enable { + if let Some(path) = &conf.persistence.path { + tracker.load_state(path)?; + } else { + return Err(anyhow!( + "tracker persistence enabled yet no specified load state path found" + ) + .into()); + }; + }; + + apply_highlights(HighlightList::default())?; + + let worker = Worker::new(conf.tracker.enable.then_some(tracker)); + worker.run_jobs()?; + + Ok(()) + } +} diff --git a/compass/src/lib.rs b/compass/src/lib.rs new file mode 100644 index 0000000..94158e1 --- /dev/null +++ b/compass/src/lib.rs @@ -0,0 +1,23 @@ +mod common_types; + +mod config; + +mod frecency; + +mod functions; + +mod bootstrap; + +mod error; +use error::*; + +mod ui; + +mod viml; + +mod state; + +#[nvim_oxi::plugin] +pub fn compass() -> Result { + bootstrap::init() +} diff --git a/compass/src/state/mod.rs b/compass/src/state/mod.rs new file mode 100644 index 0000000..3bce750 --- /dev/null +++ b/compass/src/state/mod.rs @@ -0,0 +1,17 @@ +mod record; +pub use record::{ChangeTypeRecord, Record, Tick, TypeRecord}; + +mod session; +use session::*; + +mod namespace; +pub use namespace::*; + +mod tracker; +pub use tracker::{SyncTracker, Tracker}; + +mod track_list; +pub use track_list::TrackList; + +mod worker; +pub use worker::Worker; diff --git a/compass/src/state/namespace.rs b/compass/src/state/namespace.rs new file mode 100644 index 0000000..09305d5 --- /dev/null +++ b/compass/src/state/namespace.rs @@ -0,0 +1,55 @@ +use nvim_oxi::api::create_namespace; +use serde::de; + +#[derive(Clone, Copy, Debug)] +pub struct Namespace { + id: u32, +} + +static NAMESPACE: std::sync::OnceLock = std::sync::OnceLock::new(); +pub fn get_namespace() -> Namespace { + *NAMESPACE.get_or_init(Namespace::default) +} + +impl Default for Namespace { + fn default() -> Self { + create_namespace("compass").into() + } +} + +impl<'de> de::Deserialize<'de> for Namespace { + fn deserialize(deserializer: D) -> std::result::Result + where + D: de::Deserializer<'de>, + { + struct NamespaceVisitor; + + impl<'de> de::Visitor<'de> for NamespaceVisitor { + type Value = Namespace; + + fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.write_str("namespace name as a string") + } + + fn visit_str(self, v: &str) -> Result + where + E: de::Error, + { + Ok(create_namespace(v).into()) + } + } + + deserializer.deserialize_str(NamespaceVisitor) + } +} + +impl From for Namespace { + fn from(id: u32) -> Self { + Self { id } + } +} +impl From for u32 { + fn from(val: Namespace) -> Self { + val.id + } +} diff --git a/compass/src/state/record.rs b/compass/src/state/record.rs new file mode 100644 index 0000000..a0441ef --- /dev/null +++ b/compass/src/state/record.rs @@ -0,0 +1,185 @@ +use crate::{ + common_types::{CursorPosition, Extmark}, + frecency::{Frecency, FrecencyScore, FrecencyType, FrecencyWeight}, + state::track_list::IndicateCloseness, + ui::record_mark::{create_record_mark, update_record_mark, RecordMarkTime}, + Result, +}; +use std::fmt::Display; + +use bitcode::{Decode, Encode}; +use nvim_oxi::api::{command, Buffer, Window}; +use serde::Deserialize; + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct Record { + pub buf: Buffer, + pub typ: TypeRecord, + pub extmark: Extmark, + pub frecency: Frecency, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Decode, Encode)] +pub enum TypeRecord { + Change(ChangeTypeRecord), +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Decode, Encode, Deserialize)] +#[serde(transparent)] +pub struct Tick(i32); + +impl Display for Tick { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl From for Tick { + fn from(value: i32) -> Self { + Self(value) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Decode, Encode)] +pub enum ChangeTypeRecord { + Tick(Tick), + Manual(Option), +} + +impl TypeRecord { + pub fn tick(self) -> Option { + match self { + Self::Change(c) => match c { + ChangeTypeRecord::Tick(t) => Some(t), + ChangeTypeRecord::Manual(t) => t, + }, + } + } +} + +impl Record { + pub fn try_new(buf: Buffer, typ: TypeRecord, pos: &CursorPosition) -> Result { + let extmark = create_record_mark(buf.clone(), pos, RecordMarkTime::PastClose)?; + + Ok(Self { + buf, + typ, + extmark, + frecency: Frecency::new(), + }) + } + + pub fn update( + &mut self, + buf: Buffer, + typ: TypeRecord, + pos: &CursorPosition, + hl: RecordMarkTime, + ) -> Result<()> { + update_record_mark(&self.extmark, buf.clone(), pos, hl)?; + + self.typ = typ; + + self.frecency.add_record(FrecencyType::Update); + + Ok(()) + } + + fn jump(&mut self, mut win: Window) -> Result<()> { + let CursorPosition { line, col } = self.extmark.get_pos(self.buf.clone()); + + command("normal! m'")?; + win.set_buf(&self.buf)?; + win.set_cursor(line, col)?; + + Ok(()) + } + + pub fn goto(&mut self, win: Window, typ: FrecencyType) -> Result<()> { + self.jump(win)?; + + self.frecency.add_record(typ); + + Ok(()) + } + + pub fn pop(&mut self, win: Window) -> Result<()> { + self.jump(win)?; + + self.extmark.delete(self.buf.clone())?; + + Ok(()) + } +} + +impl IndicateCloseness for Record { + fn as_past(&self) { + let _ = update_record_mark( + &self.extmark, + self.buf.clone(), + &self.extmark.get_pos(self.buf.clone()), + RecordMarkTime::Past, + ); + } + fn as_future(&self) { + let _ = update_record_mark( + &self.extmark, + self.buf.clone(), + &self.extmark.get_pos(self.buf.clone()), + RecordMarkTime::Future, + ); + } + fn as_close_past(&self) { + let _ = update_record_mark( + &self.extmark, + self.buf.clone(), + &self.extmark.get_pos(self.buf.clone()), + RecordMarkTime::PastClose, + ); + } + fn as_close_future(&self) { + let _ = update_record_mark( + &self.extmark, + self.buf.clone(), + &self.extmark.get_pos(self.buf.clone()), + RecordMarkTime::FutureClose, + ); + } +} + +impl FrecencyScore for Record { + fn total_score(&self) -> FrecencyWeight { + self.frecency.total_score() + } +} + +mod tests { + use crate::state::get_namespace; + + use super::*; + + use nvim_oxi::api::{get_current_buf, opts::GetExtmarksOpts, types::ExtmarkPosition}; + + #[nvim_oxi::test] + fn can_create_record_in_current_buffer() { + let buf = get_current_buf(); + let pos = CursorPosition::from((1, 0)); + + let got = Record::try_new( + buf.clone(), + TypeRecord::Change(ChangeTypeRecord::Tick(15.into())), + &pos, + ) + .unwrap(); + + assert!(buf + .get_extmarks( + get_namespace().into(), + ExtmarkPosition::ByTuple((0, 0)), + ExtmarkPosition::ByTuple((100, 100)), + &GetExtmarksOpts::builder().build(), + ) + .unwrap() + .any(|e| e.0 == Into::::into(&got.extmark))); + } +} diff --git a/compass/src/state/session.rs b/compass/src/state/session.rs new file mode 100644 index 0000000..a296992 --- /dev/null +++ b/compass/src/state/session.rs @@ -0,0 +1,69 @@ +mod data_session; +pub use data_session::*; + +use crate::{state::Record, state::TrackList, Result}; +use std::{ + fs::File, + io::{BufReader, BufWriter, Read, Write}, + path::Path, +}; + +use anyhow::anyhow; + +pub fn save_session(data: Session, path: &Path) -> Result<()> { + let file = File::create(path).map_err(|e| anyhow!("{e}"))?; + let mut buf = BufWriter::new(file); + + buf.write_all(&bitcode::encode(&data)) + .map_err(|e| anyhow!("{e}"))?; + + Ok(()) +} + +pub fn load_session(path: &Path) -> Result> { + let file = File::open(path).map_err(|e| anyhow!("{e}"))?; + let mut bytes = Vec::new(); + BufReader::new(file) + .read_to_end(&mut bytes) + .map_err(|e| anyhow!("{e}"))?; + let session: Session = bitcode::decode(&bytes).map_err(|e| anyhow!("{e}"))?; + + session.try_into() +} + +mod tests { + use super::*; + + use nvim_oxi::api::get_current_buf; + + use crate::state::{ChangeTypeRecord, TypeRecord}; + + #[nvim_oxi::test] + fn can_save_and_load_session() { + let list = { + let mut list = TrackList::default(); + for i in 0..=3 { + list.push( + Record::try_new( + get_current_buf(), + TypeRecord::Change(ChangeTypeRecord::Tick(i.into())), + &(1, 0).into(), + ) + .unwrap(), + ); + } + list + }; + + let mut path = std::env::temp_dir(); + path.push("compass_session_load_test_file"); + + save_session(Session::try_from(&list).unwrap(), &path).unwrap(); + + let got = load_session(&path).unwrap(); + let mut want = list.iter_from_future(); + for r in got.iter_from_future() { + assert_eq!(r.typ, want.next().unwrap().typ); + } + } +} diff --git a/compass/src/state/session/data_session.rs b/compass/src/state/session/data_session.rs new file mode 100644 index 0000000..a7e6fad --- /dev/null +++ b/compass/src/state/session/data_session.rs @@ -0,0 +1,110 @@ +use crate::{ + common_types::CursorPosition, + frecency::Frecency, + state::{Record, TrackList, TypeRecord}, + ui::record_mark::{create_record_mark, recreate_mark_time}, + Error, Result, +}; + +use bitcode::{Decode, Encode}; + +#[derive(Decode, Encode)] +pub struct Session { + pub version: Version, + pub data: DataSession, +} + +#[derive(Default, Decode, Encode)] +pub struct DataSession { + pub pos: Option, + pub records: Vec, +} + +#[derive(Decode, Encode)] +pub struct PersistentRecord { + pub buf_handle: i32, + pub typ: TypeRecord, + pub frecency: Frecency, + pub cursor: CursorPosition, +} + +#[derive(Decode, Encode)] +pub enum Version { + One = 1, +} + +impl TryFrom<&Record> for PersistentRecord { + type Error = Error; + + fn try_from( + Record { + buf, + typ, + extmark, + frecency, + }: &Record, + ) -> Result { + let cursor = extmark.get_pos(buf.clone()); + + Ok(Self { + buf_handle: buf.handle(), + typ: *typ, + cursor, + // TODO: this is bad + frecency: frecency.clone(), + }) + } +} + +impl TryFrom<&TrackList> for Session { + type Error = Error; + + fn try_from(data: &TrackList) -> Result { + let mut records: Vec = Vec::with_capacity(data.len()); + for r in data.iter_from_future() { + records.push(r.try_into()?); + } + + Ok(Self { + version: Version::One, + data: DataSession { + pos: data.pos, + records, + }, + }) + } +} + +impl TryFrom for TrackList { + type Error = Error; + + fn try_from(session: Session) -> Result { + let mut track_list: TrackList = + TrackList::with_capacity(session.data.records.len(), session.data.pos); + + for (i, r) in session.data.records.into_iter().enumerate() { + let extmark = create_record_mark( + r.buf_handle.into(), + &r.cursor, + recreate_mark_time(i, track_list.pos), + )?; + track_list.push_plain(Record { + buf: r.buf_handle.into(), + typ: r.typ, + extmark, + frecency: r.frecency, + }); + } + + Ok(track_list) + } +} + +impl Default for Session { + fn default() -> Self { + Self { + version: Version::One, + data: DataSession::default(), + } + } +} diff --git a/compass/src/state/track_list.rs b/compass/src/state/track_list.rs new file mode 100644 index 0000000..a0088e2 --- /dev/null +++ b/compass/src/state/track_list.rs @@ -0,0 +1,720 @@ +use std::collections::vec_deque::{Iter, IterMut, VecDeque}; + +use crate::frecency::FrecencyScore; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TrackList { + ring: VecDeque, + pub pos: Option, +} + +impl Default for TrackList { + fn default() -> Self { + Self { + ring: VecDeque::default(), + pos: None, + } + } +} + +impl TrackList { + pub fn with_capacity(capacity: usize, pos: Option) -> Self { + Self { + ring: VecDeque::with_capacity(capacity), + pos, + } + } + + pub fn len(&self) -> usize { + self.ring.len() + } + + pub fn iter_from_future(&self) -> Iter { + self.ring.iter() + } + + pub fn iter_mut_from_future(&mut self) -> IterMut { + self.ring.iter_mut() + } + + fn past_exists(&self) -> bool { + if self.ring.is_empty() { + return false; + } + + let Some(p) = self.pos else { + return true; + }; + + p + 1 < self.ring.len() + } + + pub fn get_close_past(&self) -> Option<&T> { + match self.pos { + Some(p) => self.get(p + 1), + None => self.ring.front(), + } + } + + pub fn get(&self, i: usize) -> Option<&T> { + self.ring.get(i) + } + + pub fn get_mut(&mut self, i: usize) -> Option<&mut T> { + self.ring.get_mut(i) + } + + /// Push an element without triggering domain side effects + pub fn push_plain(&mut self, val: T) { + self.ring.push_back(val); + } +} + +impl TrackList +where + T: IndicateCloseness, +{ + pub fn push(&mut self, val: T) { + match self.pos { + Some(p) => { + if let Some(old_close) = self.ring.get(p + 1) { + old_close.as_past() + }; + + self.ring.insert(p + 1, val); + } + None => { + if let Some(first) = self.ring.front() { + first.as_past(); + }; + + self.ring.push_front(val); + } + } + } + + pub fn step_past_mut(&mut self) -> Option<&mut T> { + if !self.past_exists() { + return None; + }; + + let pos = self.pos.map(|p| p + 1).unwrap_or(0); + self.pos = Some(pos); + + if let Some(close_past) = self.ring.get(pos + 1) { + close_past.as_close_past(); + }; + if let Some(i) = pos.checked_sub(1) { + if let Some(fut) = self.ring.get(i) { + fut.as_future(); + }; + }; + + let curr = self.ring.get_mut(pos)?; + curr.as_close_future(); + + Some(curr) + } + + pub fn step_future_mut(&mut self) -> Option<&mut T> { + let pos = self.pos?; + + if let Some(past) = self.ring.get(pos + 1) { + past.as_past(); + }; + if let Some(i) = pos.checked_sub(1) { + if let Some(close_fut) = self.ring.get(i) { + close_fut.as_close_future(); + }; + }; + + self.pos = pos.checked_sub(1); + + let curr = self.ring.get_mut(pos)?; + curr.as_close_past(); + + Some(curr) + } + + pub fn make_close_past(&mut self, idx: usize) -> Option<()> { + if let Some(p) = self.pos { + if p + 1 == idx { + return Some(()); + } + } + + let val = self.ring.get(idx)?; + val.as_close_past(); + + let len = self.len(); + if len == 1 { + self.pos = None; + return Some(()); + } + + match self.pos { + Some(p) => { + match idx.ge(&p) { + // past -> close past + true => { + if let Some(old_close) = self.get_close_past() { + old_close.as_past() + }; + + self.ring.swap(idx, p); + + if p + 1 < idx { + self.ring.make_contiguous()[p..=idx].rotate_right(1); + } else { + self.pos = p.checked_sub(1); + } + + Some(()) + } + + // future -> close past + false => { + match p.checked_sub(1) { + Some(new_pos) => { + if p == idx { + if let Some(close_new) = self.ring.get(new_pos) { + close_new.as_close_future(); + }; + } + + self.pos = Some(new_pos); + //self.ring.swap(idx, new_pos); + self.ring.swap(idx, p); + self.ring.make_contiguous()[idx..=new_pos].rotate_right(1); + } + None => { + self.pos = None; + } + } + + Some(()) + } + } + } + None => { + if idx != 0 { + self.ring.front()?.as_past(); + } + + self.ring.make_contiguous()[0..=idx].rotate_right(1); + + Some(()) + } + } + } + + pub fn pop_past(&mut self) -> Option { + if !self.past_exists() { + return None; + }; + + let pos = self.pos.map(|p| p + 1).unwrap_or(0); + + if let Some(new_close) = self.ring.get(pos + 1) { + new_close.as_close_past(); + }; + + self.ring.remove(pos) + } + + pub fn pop_future(&mut self) -> Option { + let pos = self.pos?; + let new_pos = pos.checked_sub(1); + + if let Some(i) = new_pos { + if let Some(new_close) = self.ring.get(i) { + new_close.as_close_future(); + } + } + + self.pos = new_pos; + + self.ring.remove(pos) + } +} + +impl TrackList +where + T: FrecencyScore, +{ + pub fn frecency(&self) -> Vec<(usize, &T)> { + let mut vec: Vec<(usize, &T)> = self.ring.iter().enumerate().collect(); + vec.sort_by_key(|(_, v)| v.total_score()); + vec + } +} + +pub trait IndicateCloseness { + fn as_past(&self); + fn as_future(&self); + + fn as_close_past(&self); + fn as_close_future(&self); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + struct Num(i32); + impl From for Num { + fn from(value: i32) -> Self { + Num(value) + } + } + impl IndicateCloseness for Num { + fn as_past(&self) {} + fn as_future(&self) {} + fn as_close_past(&self) {} + fn as_close_future(&self) {} + } + + #[test] + fn can_go_to_oldest() { + let mut list = TrackList::::default(); + list.push(1.into()); + list.push(2.into()); + list.push(3.into()); + list.push(4.into()); + + assert_eq!(list.ring.len(), 4); + + assert!(list.pos.is_none()); + + assert_eq!(list.step_past_mut().unwrap(), &4.into()); + assert_eq!(list.step_past_mut().unwrap(), &3.into()); + + assert_eq!(list.step_future_mut().unwrap(), &3.into()); + + assert_eq!(list.step_past_mut().unwrap(), &3.into()); + assert_eq!(list.step_past_mut().unwrap(), &2.into()); + assert_eq!(list.step_past_mut().unwrap(), &1.into()); + } + + #[test] + fn prevents_out_of_bounds() { + let mut list = TrackList::::default(); + list.push(1.into()); + list.push(2.into()); + list.push(3.into()); + + assert_eq!(list.step_future_mut(), None); + assert_eq!(list.step_future_mut(), None); + + assert!(list.pos.is_none()); + assert_eq!(list.step_future_mut(), None); + assert_eq!(list.step_future_mut(), None); + + assert_eq!(list.step_past_mut().unwrap(), &3.into()); + + list.push(22.into()); + + assert_eq!(list.step_past_mut().unwrap(), &22.into()); + assert_eq!(list.step_past_mut().unwrap(), &2.into()); + assert_eq!(list.step_past_mut().unwrap(), &1.into()); + + assert_eq!(list.step_past_mut(), None); + assert_eq!(list.get(list.pos.unwrap()).unwrap(), &1.into()); + assert_eq!(list.step_past_mut(), None); + assert_eq!(list.step_future_mut().unwrap(), &1.into()); + assert_eq!(list.step_future_mut().unwrap(), &2.into()); + assert_eq!(list.step_future_mut().unwrap(), &22.into()); + assert_eq!(list.step_future_mut().unwrap(), &3.into()); + } + + #[test] + fn inserts_to_the_right_when_not_at_start() { + let mut list = TrackList::::default(); + list.push(1.into()); + list.push(2.into()); + list.push(3.into()); + + assert_eq!(list.step_past_mut().unwrap(), &3.into()); + list.push(33.into()); + assert_eq!(list.step_past_mut().unwrap(), &33.into()); + } + + #[test] + fn can_push_when_at_end() { + let mut list = TrackList::::default(); + list.push(1.into()); + list.push(2.into()); + list.push(3.into()); + + assert_eq!(list.step_past_mut().unwrap(), &3.into()); + assert_eq!(list.step_past_mut().unwrap(), &2.into()); + assert_eq!(list.step_past_mut().unwrap(), &1.into()); + + list.push(0.into()); + assert_eq!(list.step_past_mut().unwrap(), &0.into()); + } + + #[test] + fn does_not_stuck_with_single_element() { + let mut list = TrackList::::default(); + list.push(1.into()); + + assert_eq!(list.step_past_mut().unwrap(), &1.into()); + assert_eq!(list.step_past_mut(), None); + assert_eq!(list.step_future_mut().unwrap(), &1.into()); + assert_eq!(list.step_future_mut(), None); + assert_eq!(list.step_future_mut(), None); + assert_eq!(list.step_past_mut().unwrap(), &1.into()); + } + + #[test] + fn can_make_second_oldest_element_close_past_while_inside() { + let mut list = TrackList::::default(); + list.push(1.into()); + list.push(2.into()); // will become close past + list.push(3.into()); + list.push(4.into()); + list.push(5.into()); // we are here + list.pos = Some(0); + + list.make_close_past(3); + + let want = VecDeque::::from([5.into(), 2.into(), 4.into(), 3.into(), 1.into()]); + assert_eq!(list.ring, want); + assert_eq!(list.pos, Some(0)); + } + + #[test] + fn can_make_newest_element_close_past_while_inside_middle() { + let mut list = TrackList::::default(); + list.push(1.into()); + list.push(2.into()); // we are here + list.push(3.into()); + list.push(4.into()); // will become close past + list.pos = Some(2); + + list.make_close_past(0); + + let want = VecDeque::::from([3.into(), 2.into(), 4.into(), 1.into()]); + assert_eq!(list.ring, want); + assert_eq!(list.pos, Some(1)); + } + + #[test] + fn can_make_newest_element_close_past_while_inside_middle_next_to() { + let mut list = TrackList::::default(); + list.push(1.into()); + list.push(2.into()); // we are here + list.push(3.into()); // will become close past + list.push(4.into()); + list.pos = Some(2); + + list.make_close_past(1); + + let want = VecDeque::::from([4.into(), 2.into(), 3.into(), 1.into()]); + assert_eq!(list.ring, want); + assert_eq!(list.pos, Some(1)); + } + + #[test] + fn can_make_oldest_element_close_past_while_inside() { + let mut list = TrackList::::default(); + list.push(1.into()); // will become close past + list.push(2.into()); + list.push(3.into()); + list.push(4.into()); + list.push(5.into()); // we are here + list.pos = Some(0); + + list.make_close_past(4); + + let want = VecDeque::::from([5.into(), 1.into(), 4.into(), 3.into(), 2.into()]); + assert_eq!(list.ring, want); + assert_eq!(list.pos, Some(0)); + } + + #[test] + fn can_make_middle_element_close_past_while_outside() { + let mut list = TrackList::::default(); + list.push(1.into()); + list.push(2.into()); + list.push(3.into()); // will become close past + list.push(4.into()); + list.push(5.into()); + // we are here + list.pos = None; + + list.make_close_past(2); + + let want = VecDeque::::from([3.into(), 5.into(), 4.into(), 2.into(), 1.into()]); + assert_eq!(list.ring, want); + assert_eq!(list.pos, None); + } + + #[test] + fn can_make_second_element_close_past_while_outside() { + let mut list = TrackList::::default(); + list.push(1.into()); + list.push(2.into()); + list.push(3.into()); + list.push(4.into()); // will become close past + list.push(5.into()); + // we are here + list.pos = None; + + list.make_close_past(1); + + let want = VecDeque::::from([4.into(), 5.into(), 3.into(), 2.into(), 1.into()]); + assert_eq!(list.ring, want); + assert_eq!(list.pos, None); + } + + #[test] + fn can_make_fourth_element_close_past_while_outside() { + let mut list = TrackList::::default(); + list.push(1.into()); + list.push(2.into()); // will become close past + list.push(3.into()); + list.push(4.into()); + list.push(5.into()); + // we are here + list.pos = None; + + list.make_close_past(3); + + let want = VecDeque::::from([2.into(), 5.into(), 4.into(), 3.into(), 1.into()]); + assert_eq!(list.ring, want); + assert_eq!(list.pos, None); + } + + #[test] + fn can_make_last_elem_of_two_close_past_while_outside() { + let mut list = TrackList::::default(); + list.push(1.into()); // will become close past + list.push(2.into()); + // we are here + list.pos = None; + + list.make_close_past(1); + + let want = VecDeque::::from([1.into(), 2.into()]); + assert_eq!(list.ring, want); + assert_eq!(list.pos, None); + } + + #[test] + fn can_make_first_elem_of_two_close_past_while_outside() { + let mut list = TrackList::::default(); + list.push(1.into()); + list.push(2.into()); // will become close past (already is) + // we are here + list.pos = None; + + list.make_close_past(0); + + let want = VecDeque::::from([2.into(), 1.into()]); + assert_eq!(list.ring, want); + assert_eq!(list.pos, None); + } + + #[test] + fn can_make_last_elem_of_two_close_past_while_inside_it() { + let mut list = TrackList::::default(); + list.push(1.into()); // we are here and it will become close past + list.push(2.into()); + list.pos = Some(1); + + list.make_close_past(1); + + let want = VecDeque::::from([2.into(), 1.into()]); + assert_eq!(list.ring, want); + assert_eq!(list.pos, Some(0)); + } + + #[test] + fn can_make_first_elem_of_two_close_past_while_inside_it() { + let mut list = TrackList::::default(); + list.push(1.into()); + list.push(2.into()); // we are here and it will become close past + list.pos = Some(0); + + list.make_close_past(0); + + let want = VecDeque::::from([2.into(), 1.into()]); + assert_eq!(list.ring, want); + assert_eq!(list.pos, None); + + list.make_close_past(0); + + assert_eq!(list.ring, want); + assert_eq!(list.pos, None); + + list.make_close_past(1); + + let want = VecDeque::::from([1.into(), 2.into()]); + assert_eq!(list.ring, want); + assert_eq!(list.pos, None); + } + + #[test] + fn can_make_last_elem_close_past_while_outside() { + let mut list = TrackList::::default(); + list.push(1.into()); // will become close past + list.push(2.into()); + list.push(3.into()); + list.push(4.into()); + // we are here + list.pos = None; + + list.make_close_past(3); + + let want = VecDeque::::from([1.into(), 4.into(), 3.into(), 2.into()]); + assert_eq!(list.ring, want); + assert_eq!(list.pos, None); + } + + #[test] + fn can_make_first_elem_close_past_while_outside() { + let mut list = TrackList::::default(); + list.push(1.into()); + list.push(2.into()); + list.push(3.into()); // will become close past (it already is) + // we are here + list.pos = None; + + list.make_close_past(0); + + let want = VecDeque::::from([3.into(), 2.into(), 1.into()]); + assert_eq!(list.ring, want); + assert_eq!(list.pos, None); + } + + #[test] + fn can_make_single_elem_close_past() { + let mut list = TrackList::::default(); + list.push(1.into()); + list.pos = Some(0); + list.make_close_past(0); + let want = VecDeque::::from([1.into()]); + assert_eq!(list.ring, want); + assert_eq!(list.pos, None); + + let mut list = TrackList::::default(); + list.push(1.into()); + list.pos = None; + list.make_close_past(0); + let want = VecDeque::::from([1.into()]); + assert_eq!(list.ring, want); + assert_eq!(list.pos, None); + } + + #[test] + fn make_close_past_does_not_get_stuck() { + let mut list = TrackList::::default(); + // we are here + list.push(1.into()); + list.push(2.into()); + list.push(3.into()); + list.push(4.into()); + list.pos = Some(3); + + list.make_close_past(1); + + let want = VecDeque::::from([4.into(), 2.into(), 1.into(), 3.into()]); + assert_eq!(list.ring, want); + assert_eq!(list.pos, Some(2)); + } + + #[test] + fn make_close_past_last_among_two_when_in_middle() { + let mut list = TrackList::::default(); + list.push(1.into()); // will become close past + list.push(2.into()); + list.pos = Some(0); + + list.make_close_past(1); + + let want = VecDeque::::from([2.into(), 1.into()]); + assert_eq!(list.ring, want); + assert_eq!(list.pos, Some(0)); + } + + #[test] + fn pop_past_when_outside() { + let mut list = TrackList::::default(); + let popped: Num = 3.into(); + list.push(1.into()); + list.push(2.into()); + list.push(popped); + // we are here + list.pos = None; + + assert_eq!(list.pop_past().unwrap(), popped); + } + + #[test] + fn pop_past_when_inside() { + let mut list = TrackList::::default(); + let popped: Num = 1.into(); + list.push(popped); + list.push(2.into()); // we are here + list.push(3.into()); + list.pos = Some(1); + + assert_eq!(list.pop_past().unwrap(), popped); + } + + #[test] + fn pop_future_when_outside_get_nothing() { + let mut list = TrackList::::default(); + let popped: Num = 3.into(); + list.push(1.into()); + list.push(2.into()); + list.push(popped); + // we are here + list.pos = None; + + assert!(list.pop_future().is_none()); + assert_eq!(list.pos, None); + } + + #[test] + fn pop_future_when_inside() { + let mut list = TrackList::::default(); + let popped: Num = 2.into(); + list.push(1.into()); + list.push(popped); // we are here + list.push(3.into()); + list.pos = Some(1); + + assert_eq!(list.pop_future().unwrap(), popped); + assert_eq!(list.pos, Some(0)); + } + + #[test] + fn pop_future_when_in_end() { + let mut list = TrackList::::default(); + let popped: Num = 1.into(); + list.push(popped); // we are here + list.push(2.into()); + list.push(3.into()); + list.pos = Some(2); + + assert_eq!(list.pop_future().unwrap(), popped); + assert_eq!(list.pos, Some(1)); + } + + #[test] + fn pop_future_when_at_start() { + let mut list = TrackList::::default(); + let popped: Num = 3.into(); + list.push(1.into()); + list.push(2.into()); + list.push(popped); // we are here + list.pos = Some(0); + + assert_eq!(list.pop_future().unwrap(), popped); + assert_eq!(list.pos, None); + } +} diff --git a/compass/src/state/tracker.rs b/compass/src/state/tracker.rs new file mode 100644 index 0000000..b7fd91f --- /dev/null +++ b/compass/src/state/tracker.rs @@ -0,0 +1,202 @@ +use super::{load_session, save_session, Session, Tick}; +use crate::{ + common_types::CursorPosition, + config::get_config, + state::{ChangeTypeRecord, Record, TrackList, TypeRecord}, + ui::record_mark::{recreate_mark_time, update_record_mark, RecordMarkTime}, + Result, +}; +use std::{ + path::Path, + sync::{Arc, Mutex, MutexGuard}, + time::{Duration, Instant}, +}; + +use anyhow::anyhow; +use nvim_oxi::api::{ + get_current_buf, get_current_win, get_mode, get_option_value, + opts::{OptionOpts, OptionScope}, + types::{GotMode, Mode}, + Buffer, +}; + +pub struct Tracker { + pub list: TrackList, + last_flush: std::time::Instant, + + renewed_bufs: Vec, +} + +fn is_initial_tick(tick: Tick) -> bool { + const INITIAL_CHANGEDTICK: i32 = 2; + tick == INITIAL_CHANGEDTICK.into() +} + +impl Tracker { + fn persist_state(&mut self, path: &Path) -> Result<()> { + if self.last_flush.elapsed() >= Duration::from_secs(5) { + save_session(Session::try_from(&self.list)?, path)?; + self.last_flush = Instant::now(); + } + + Ok(()) + } + + fn renew_buf_record_mark(&mut self, curr_buf: Buffer) -> Result<()> { + if self.renewed_bufs.iter().any(|b| b.clone() == curr_buf) { + return Ok(()); + }; + + if let Some((i, r)) = self + .list + .iter_from_future() + .enumerate() + .find(|(_, r)| r.buf == curr_buf) + { + update_record_mark( + &r.extmark, + r.buf.clone(), + &r.extmark.get_pos(r.buf.clone()), + recreate_mark_time(i, self.list.pos), + )?; + + self.renewed_bufs.push(curr_buf); + }; + + Ok(()) + } + + fn track(&mut self, buf_new: Buffer) -> Result<()> { + let Ok(GotMode { + mode: Mode::Normal, .. + }) = get_mode() + else { + return Ok(()); + }; + + let modified: bool = get_option_value( + "modified", + &OptionOpts::builder().scope(OptionScope::Local).build(), + ) + .unwrap_or(true); + if !modified { + return Ok(()); + } + + let type_buf: String = get_option_value( + "buftype", + &OptionOpts::builder().scope(OptionScope::Local).build(), + )?; + // Skip special buffers + if !type_buf.is_empty() { + return Ok(()); + } + + let tick_new = { + let Ok(tick_new) = buf_new + .get_var::("changedtick") + .map(Into::::into) + else { + return Ok(()); + }; + if is_initial_tick(tick_new) { + return Ok(()); + }; + if !self.list.iter_from_future().all(|Record { buf, typ, .. }| { + (match typ { + TypeRecord::Change(ChangeTypeRecord::Tick(t)) => tick_new != *t, + _ => true, + }) || buf_new != *buf + }) { + return Ok(()); + } + + TypeRecord::Change(ChangeTypeRecord::Tick(tick_new)) + }; + + let win = get_current_win(); + let pos_new: CursorPosition = win.get_cursor()?.into(); + + if let Some((i, nearby_record)) = + self.list + .iter_mut_from_future() + .enumerate() + .find(|(_, Record { buf, extmark, .. })| { + buf_new == *buf && { extmark.get_pos(buf_new.clone()).is_nearby(&pos_new) } + }) + { + nearby_record.update(buf_new, tick_new, &pos_new, RecordMarkTime::PastClose)?; + + self.list.make_close_past(i); + return Ok(()); + }; + + let record_new = Record::try_new(buf_new, tick_new, &pos_new)?; + + self.list.push(record_new); + + Ok(()) + } +} + +impl Default for Tracker { + fn default() -> Self { + Self { + list: TrackList::default(), + last_flush: Instant::now(), + renewed_bufs: Vec::default(), + } + } +} + +#[derive(Clone)] +pub struct SyncTracker(Arc>); + +impl SyncTracker { + pub fn run(&mut self) -> Result<()> { + let buf_curr = get_current_buf(); + + let mut tracker = self.lock()?; + + tracker.track(buf_curr.clone())?; + + let conf = get_config(); + + if conf.persistence.enable { + tracker.renew_buf_record_mark(buf_curr)?; + + let path = conf.persistence.path.as_ref().ok_or_else(|| { + anyhow!( + "changes tracker persistence enabled yet no specified save state path found" + ) + })?; + tracker.persist_state(path)?; + }; + + Ok(()) + } + + pub fn lock(&self) -> Result> { + Ok(self.0.lock().map_err(|e| anyhow!("{e}"))?) + } + + pub fn load_state(&mut self, path: &Path) -> Result<()> { + let mut tracker = self.lock()?; + + tracker.list = load_session(path).unwrap_or_default(); + + Ok(()) + } +} + +impl Default for SyncTracker { + fn default() -> Self { + Tracker::default().into() + } +} + +impl From for SyncTracker { + fn from(value: Tracker) -> Self { + Self(Arc::new(Mutex::new(value))) + } +} diff --git a/compass/src/state/worker.rs b/compass/src/state/worker.rs new file mode 100644 index 0000000..74cb5f7 --- /dev/null +++ b/compass/src/state/worker.rs @@ -0,0 +1,28 @@ +use crate::Result; +use std::time::Duration; + +use super::SyncTracker; + +pub struct Worker { + pub tracker: Option, +} + +impl Worker { + pub fn new(tracker: Option) -> Self { + Self { tracker } + } + + pub fn run_jobs(mut self) -> Result<()> { + std::thread::spawn(move || -> Result<()> { + loop { + std::thread::sleep(Duration::from_millis(200)); + + if let Some(tracker) = &mut self.tracker { + tracker.run()?; + }; + } + }); + + Ok(()) + } +} diff --git a/compass/src/ui/ensure_buf_loaded.rs b/compass/src/ui/ensure_buf_loaded.rs new file mode 100644 index 0000000..b8b31b8 --- /dev/null +++ b/compass/src/ui/ensure_buf_loaded.rs @@ -0,0 +1,23 @@ +use crate::{ + state::Record, + ui::record_mark::{recreate_mark_time, update_record_mark}, + Result, +}; + +use nvim_oxi::api::set_current_buf; + +pub fn ensure_buf_loaded( + idx: usize, + pos_record_list: Option, + record: &Record, +) -> Result<()> { + set_current_buf(&record.buf)?; + update_record_mark( + &record.extmark, + record.buf.clone(), + &record.extmark.get_pos(record.buf.clone()), + recreate_mark_time(idx, pos_record_list), + )?; + + Ok(()) +} diff --git a/compass/src/ui/grid.rs b/compass/src/ui/grid.rs new file mode 100644 index 0000000..bdb69f3 --- /dev/null +++ b/compass/src/ui/grid.rs @@ -0,0 +1,268 @@ +use crate::{ + common_types::{CursorPosition, Extmark}, + config::JumpKeymap, + config::WindowGridSize, + state::{ChangeTypeRecord, Record, TypeRecord}, + ui::{record_mark::create_hint_mark, tab::open_tab}, + InputError, Result, +}; + +use anyhow::Context; +use nvim_oxi::{ + api::{ + command, create_augroup, create_autocmd, create_buf, del_augroup_by_id, get_current_win, + get_option_value, open_win, + opts::{CreateAugroupOpts, CreateAutocmdOpts, OptionOpts, SetKeymapOpts}, + set_current_win, set_option_value, + types::{Mode, WindowConfig, WindowRelativeTo}, + Buffer, Window, + }, + Function, +}; + +#[derive(Clone, Copy)] +pub enum GridLayout { + Open, + Follow, +} + +pub fn open_grid<'a>( + slice_record: &[&Record], + limit_win: WindowGridSize, + layout: GridLayout, + mut jump_iter: impl Iterator, +) -> Result<()> { + let len_record = slice_record.len(); + if len_record == 0 { + Err(InputError::NoRecords("record list is empty".to_owned()))? + }; + + open_tab()?; + + let mut result: Vec<(Window, Buffer, (Extmark, Option))> = + Vec::with_capacity(limit_win.into()); + + let conf_horiz = new_split_config(false); + let conf_vert = new_split_config(true); + let (first_len_half, second_len_half) = calc_halves(len_record, limit_win); + + let mut record_iter = slice_record.iter(); + + let (mut hidden_win, hidden_buf, old_guicursor) = open_hidden_float()?; + let mut create_hint = + |record: &Record, pos: &CursorPosition| -> Result<(Extmark, Option)> { + let jump_keymap = jump_iter + .next() + .with_context(|| "no jump keymap to create a hint with")?; + + set_buffer_jump_keymap(hidden_buf.clone(), jump_keymap, record, &layout, limit_win)?; + create_hint_mark(record.buf.clone(), pos, jump_keymap.follow.as_str(), layout) + }; + + for i in 0..first_len_half { + let record = record_iter + .next() + .with_context(|| "no next buffer to iterate over")?; + + let pos = record.extmark.get_pos(record.buf.clone()); + let mut win = { + if i == 0 { + let mut w = get_current_win(); + w.set_buf(&record.buf)?; + w + } else { + open_win(&record.buf, false, &conf_vert)? + } + }; + win.set_cursor(pos.line, pos.col)?; + command("normal! zz")?; + + let hint = create_hint(record, &pos)?; + + result.push((win, record.buf.clone(), hint)); + } + for i in 0..second_len_half { + let record = record_iter + .next() + .with_context(|| "no next buffer to iterate over")?; + set_current_win({ + let (win, _, _) = &result.get(i).with_context(|| "no win in the results vec")?; + win + })?; + + let pos = record.extmark.get_pos(record.buf.clone()); + let mut win = open_win(&record.buf, false, &conf_horiz)?; + win.set_cursor(pos.line, pos.col)?; + command("normal! zz")?; + + let hint = create_hint(record, &pos)?; + + result.push((win, record.buf.clone(), hint)); + } + + set_current_win(&hidden_win)?; + hidden_win.set_buf(&hidden_buf)?; + + cleanup(hidden_buf.clone(), old_guicursor, result)?; + + Ok(()) +} + +fn cleanup( + hidden_buf: Buffer, + old_guicursor: String, + result: Vec<(Window, Buffer, (Extmark, Option))>, +) -> Result<()> { + let group = create_augroup( + "CompassGridCleanupGroup", + &CreateAugroupOpts::builder().clear(true).build(), + )?; + + // TODO: will not close the tab if the hidden window is somehow closed by the user for example with :q + // all what I have tried so far causes nvim instance to freeze + create_autocmd( + ["BufLeave"], + &CreateAutocmdOpts::builder() + .once(true) + .buffer(hidden_buf.clone()) + .group(group) + .callback(Function::from_fn_once(move |_| -> Result { + set_option_value("guicursor", old_guicursor, &OptionOpts::builder().build())?; + command("hi Cursor blend=0")?; + + for (win, buf, (hint, path)) in result { + hint.delete(buf.clone())?; + if let Some(path) = path { + path.delete(buf.clone())?; + } + win.close(true)?; + } + + del_augroup_by_id(group)?; + + Ok(true) + })) + .build(), + )?; + + Ok(()) +} + +fn calc_halves(record_len: usize, limit_win: WindowGridSize) -> (usize, usize) { + let half_limit_win = Into::::into(limit_win) / 2; + + let first = record_len / 2 + { + if record_len % 2 == 0 { + 0 + } else { + 1 + } + }; + let second = record_len / 2; + + ( + std::cmp::min(half_limit_win, first), + std::cmp::min(half_limit_win, second), + ) +} + +fn open_hidden_float() -> Result<(Window, Buffer, String)> { + let buf = create_buf(false, true)?; + + let win = open_win( + &buf, + false, + &WindowConfig::builder() + .relative(WindowRelativeTo::Editor) + .hide(true) + .width(1) + .height(1) + .row(0) + .col(0) + .noautocmd(true) + .build(), + )?; + + command("hi Cursor blend=100")?; + let old_guicursor: String = get_option_value("guicursor", &OptionOpts::builder().build())?; + set_option_value( + "guicursor", + "a:Cursor/lCursor", + &OptionOpts::builder().build(), + )?; + + Ok((win, buf, old_guicursor)) +} + +fn get_goto_string(record: &Record) -> String { + match record.typ { + TypeRecord::Change(typ) => match typ { + ChangeTypeRecord::Tick(t) => { + format!(r#"tick={{"buf":{},"tick":{}}}"#, record.buf.handle(), t) + } + + ChangeTypeRecord::Manual(_) => { + format!( + r#"time={{"buf":{},"millis":{}}}"#, + record.buf.handle(), + record.frecency.latest_timestamp() + ) + } + }, + } +} + +fn set_buffer_jump_keymap( + mut buf: Buffer, + JumpKeymap { follow, immediate }: &JumpKeymap, + record: &Record, + layout: &GridLayout, + limit_win: WindowGridSize, +) -> Result<()> { + buf.set_keymap( + Mode::Normal, + immediate.as_str(), + format!( + r#":tabclose:Compass goto absolute {}"#, + get_goto_string(record) + ) + .as_str(), + &SetKeymapOpts::builder() + .noremap(true) + .nowait(true) + .silent(true) + .build(), + )?; + + buf.set_keymap( + Mode::Normal, + follow.as_str(), + match layout { + GridLayout::Open => format!( + r#":tabclose:Compass follow buf target={} max_windows={}"#, + record.buf.handle(), + Into::::into(limit_win), + ), + GridLayout::Follow => format!( + r#":tabclose:Compass goto absolute {}"#, + get_goto_string(record) + ), + } + .as_str(), + &SetKeymapOpts::builder() + .noremap(true) + .nowait(true) + .silent(true) + .build(), + )?; + + Ok(()) +} + +fn new_split_config(vertical: bool) -> WindowConfig { + WindowConfig::builder() + .noautocmd(true) + .focusable(false) + .vertical(vertical) + .build() +} diff --git a/compass/src/ui/highlights.rs b/compass/src/ui/highlights.rs new file mode 100644 index 0000000..5f80ff8 --- /dev/null +++ b/compass/src/ui/highlights.rs @@ -0,0 +1,257 @@ +use super::{grid::GridLayout, record_mark::RecordMarkTime}; +use crate::Result; + +use nvim_oxi::api::command; +use serde::Deserialize; +use typed_builder::TypedBuilder; + +#[derive(Deserialize, TypedBuilder)] +pub struct OptsHighlight<'a> { + #[builder(default, setter(strip_option))] + fg: Option<&'a str>, + #[builder(default, setter(strip_option))] + bg: Option<&'a str>, + #[builder(default, setter(strip_option))] + gui: Option<&'a str>, +} + +#[derive(Deserialize)] +struct RecordHighlight<'a> { + #[serde(borrow)] + mark: OptsHighlight<'a>, + #[serde(borrow)] + sign: OptsHighlight<'a>, +} + +#[derive(Deserialize)] +pub struct RecordHighlightList<'a> { + #[serde(borrow)] + past: RecordHighlight<'a>, + #[serde(borrow)] + close_past: RecordHighlight<'a>, + #[serde(borrow)] + future: RecordHighlight<'a>, + #[serde(borrow)] + close_future: RecordHighlight<'a>, +} + +pub struct RecordHighlightNames { + pub mark: &'static str, + pub sign: &'static str, +} + +impl<'a> RecordHighlightList<'a> { + pub fn record_hl_names(typ: RecordMarkTime) -> RecordHighlightNames { + match typ { + RecordMarkTime::Past => RecordHighlightNames { + mark: "CompassRecordPast", + sign: "CompassRecordPastSign", + }, + RecordMarkTime::PastClose => RecordHighlightNames { + mark: "CompassRecordClosePast", + sign: "CompassRecordClosePastSign", + }, + RecordMarkTime::Future => RecordHighlightNames { + mark: "CompassRecordFuture", + sign: "CompassRecordFutureSign", + }, + RecordMarkTime::FutureClose => RecordHighlightNames { + mark: "CompassRecordCloseFuture", + sign: "CompassRecordCloseFutureSign", + }, + } + } +} + +impl<'a> Default for RecordHighlightList<'a> { + fn default() -> Self { + Self { + past: RecordHighlight { + mark: OptsHighlight::builder().bg("grey").gui("bold").build(), + sign: OptsHighlight::builder().fg("grey").gui("bold").build(), + }, + close_past: RecordHighlight { + mark: OptsHighlight::builder().fg("red").gui("bold").build(), + sign: OptsHighlight::builder().fg("red").gui("bold").build(), + }, + + future: RecordHighlight { + mark: OptsHighlight::builder().bg("grey").gui("bold").build(), + sign: OptsHighlight::builder().fg("grey").gui("bold").build(), + }, + close_future: RecordHighlight { + mark: OptsHighlight::builder().fg("blue").gui("bold").build(), + sign: OptsHighlight::builder().fg("blue").gui("bold").build(), + }, + } + } +} + +#[derive(Deserialize)] +struct HintHighlight<'a> { + #[serde(borrow)] + label: OptsHighlight<'a>, + #[serde(borrow)] + path: OptsHighlight<'a>, +} + +#[derive(Deserialize)] +pub struct HintHighlightList<'a> { + #[serde(borrow)] + open: HintHighlight<'a>, + #[serde(borrow)] + follow: HintHighlight<'a>, +} + +pub struct HintHighlightNames { + pub mark: &'static str, + pub path: &'static str, +} + +impl<'a> HintHighlightList<'a> { + pub fn hint_hl_names(typ: GridLayout) -> HintHighlightNames { + match typ { + GridLayout::Open => HintHighlightNames { + mark: "CompassHintOpen", + path: "CompassHintOpenPath", + }, + GridLayout::Follow => HintHighlightNames { + mark: "CompassHintFollow", + path: "CompassHintFollowPath", + }, + } + } +} + +impl<'a> Default for HintHighlightList<'a> { + fn default() -> Self { + Self { + open: HintHighlight { + label: OptsHighlight::builder() + .fg("black") + .bg("yellow") + .gui("bold") + .build(), + + path: OptsHighlight::builder().fg("yellow").gui("bold").build(), + }, + follow: HintHighlight { + label: OptsHighlight::builder() + .fg("black") + .bg("yellow") + .gui("bold") + .build(), + + path: OptsHighlight::builder().fg("yellow").gui("bold").build(), + }, + } + } +} + +#[derive(Default, Deserialize)] +pub struct HighlightList<'a> { + #[serde(borrow)] + tracks: RecordHighlightList<'a>, + #[serde(borrow)] + hints: HintHighlightList<'a>, +} + +pub struct IterHighlightList<'a> { + hls: &'a HighlightList<'a>, + index: usize, +} + +impl<'a> Iterator for IterHighlightList<'a> { + type Item = (&'static str, &'a OptsHighlight<'a>); + + fn next(&mut self) -> Option { + let result = match self.index { + 0 => Some(( + RecordHighlightList::record_hl_names(RecordMarkTime::Past).mark, + &self.hls.tracks.past.mark, + )), + 1 => Some(( + RecordHighlightList::record_hl_names(RecordMarkTime::Past).sign, + &self.hls.tracks.past.sign, + )), + 2 => Some(( + RecordHighlightList::record_hl_names(RecordMarkTime::PastClose).mark, + &self.hls.tracks.close_past.mark, + )), + 3 => Some(( + RecordHighlightList::record_hl_names(RecordMarkTime::PastClose).sign, + &self.hls.tracks.close_past.sign, + )), + 4 => Some(( + RecordHighlightList::record_hl_names(RecordMarkTime::Future).mark, + &self.hls.tracks.future.mark, + )), + 5 => Some(( + RecordHighlightList::record_hl_names(RecordMarkTime::Future).sign, + &self.hls.tracks.future.sign, + )), + 6 => Some(( + RecordHighlightList::record_hl_names(RecordMarkTime::FutureClose).mark, + &self.hls.tracks.close_future.mark, + )), + 7 => Some(( + RecordHighlightList::record_hl_names(RecordMarkTime::FutureClose).sign, + &self.hls.tracks.close_future.sign, + )), + + 8 => Some(( + HintHighlightList::hint_hl_names(GridLayout::Open).mark, + &self.hls.hints.open.label, + )), + 9 => Some(( + HintHighlightList::hint_hl_names(GridLayout::Open).path, + &self.hls.hints.open.path, + )), + 10 => Some(( + HintHighlightList::hint_hl_names(GridLayout::Follow).mark, + &self.hls.hints.follow.label, + )), + 11 => Some(( + HintHighlightList::hint_hl_names(GridLayout::Follow).path, + &self.hls.hints.follow.path, + )), + + _ => None, + }; + + self.index += 1; + result + } +} + +impl<'a> IntoIterator for &'a HighlightList<'a> { + type Item = (&'static str, &'a OptsHighlight<'a>); + type IntoIter = IterHighlightList<'a>; + + fn into_iter(self) -> Self::IntoIter { + IterHighlightList { + hls: self, + index: 0, + } + } +} + +pub fn apply_highlights(hls: HighlightList) -> Result<()> { + for (name, opts) in hls.into_iter() { + let mut cmd = format!("hi default {}", name); + + if let Some(fg) = opts.fg { + cmd.push_str(format!(" guifg={}", fg).as_str()); + } + if let Some(bg) = opts.bg { + cmd.push_str(format!(" guibg={}", bg).as_str()); + } + if let Some(gui) = opts.gui { + cmd.push_str(format!(" gui={}", gui).as_str()); + } + + command(cmd.as_str())?; + } + + Ok(()) +} diff --git a/compass/src/ui/mod.rs b/compass/src/ui/mod.rs new file mode 100644 index 0000000..8ec70a0 --- /dev/null +++ b/compass/src/ui/mod.rs @@ -0,0 +1,9 @@ +pub mod ensure_buf_loaded; + +pub mod record_mark; + +pub mod tab; + +pub mod grid; + +pub mod highlights; diff --git a/compass/src/ui/record_mark.rs b/compass/src/ui/record_mark.rs new file mode 100644 index 0000000..ee14f78 --- /dev/null +++ b/compass/src/ui/record_mark.rs @@ -0,0 +1,340 @@ +use super::{grid::GridLayout, highlights::RecordHighlightNames}; +use crate::{ + common_types::{CursorPosition, Extmark}, + config::{get_config, SignText}, + state::get_namespace, + ui::highlights::{HintHighlightList, RecordHighlightList}, + Error, Result, +}; +use std::path::{Component, Path, PathBuf}; + +use nvim_oxi::api::{ + opts::{SetExtmarkOpts, SetExtmarkOptsBuilder}, + types::{ExtmarkHlMode, ExtmarkVirtTextPosition}, + Buffer, +}; + +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +pub enum RecordMarkTime { + Past, + Future, + PastClose, + FutureClose, +} + +pub fn recreate_mark_time(i: usize, pos: Option) -> RecordMarkTime { + match pos { + Some(p) => { + if i == p { + RecordMarkTime::FutureClose + } else if i.checked_sub(1).is_some_and(|j| j == p) { + RecordMarkTime::PastClose + } else if i < p { + RecordMarkTime::Future + } else { + RecordMarkTime::Past + } + } + None => { + if i == 0 { + RecordMarkTime::PastClose + } else { + RecordMarkTime::Past + } + } + } +} + +impl From for RecordHighlightNames { + fn from(value: RecordMarkTime) -> Self { + RecordHighlightList::record_hl_names(value) + } +} + +impl From for &SignText { + fn from(value: RecordMarkTime) -> Self { + let signs = &get_config().marks.signs; + + match value { + RecordMarkTime::Past => &signs.past, + RecordMarkTime::PastClose => &signs.close_past, + RecordMarkTime::Future => &signs.future, + RecordMarkTime::FutureClose => &signs.close_future, + } + } +} + +fn basic_builder( + builder: &mut SetExtmarkOptsBuilder, + line: usize, + col: usize, +) -> &mut SetExtmarkOptsBuilder { + builder + .hl_mode(ExtmarkHlMode::Combine) + .end_row(line) + .end_col(col + 1) + .strict(false); + + builder +} + +/// Returns a 0,0 indexed position +fn get_non_blank_pos(buf: Buffer, line: usize, col: usize) -> (usize, usize) { + fn get_first_non_blank_col( + buf: Buffer, + line: usize, + maybe_col: Option, + ) -> Option<(usize, usize)> { + let mut iter_lines = buf.get_lines(line..=line, true).ok()?; + + let contents_line = iter_lines.next().map(|s| s.to_string())?; + + let col = contents_line.char_indices().position(|(i, c)| { + !c.is_whitespace() && c != '\t' && { + if let Some(col) = maybe_col { + i >= col + } else { + true + } + } + })?; + + Some((line, col)) + } + + if let Some(pos) = get_first_non_blank_col(buf.clone(), line, Some(col)) { + return pos; + }; + + for i in 1..=5 { + if let Some(pos) = get_first_non_blank_col(buf.clone(), line + i, None) { + return pos; + }; + if let Some(sub_line) = line.checked_sub(i) { + if let Some(pos) = get_first_non_blank_col(buf.clone(), sub_line, None) { + return pos; + }; + } + } + + (line, col) +} + +pub fn create_record_mark( + mut buf: Buffer, + &CursorPosition { line, col }: &CursorPosition, + time: RecordMarkTime, +) -> Result { + let sign_text: &SignText = time.into(); + + let (line, col) = get_non_blank_pos(buf.clone(), line.saturating_sub(1), col); + + let hl: RecordHighlightNames = time.into(); + let set_opts = &basic_builder(&mut SetExtmarkOpts::builder(), line, col) + .hl_group(hl.mark) + .sign_hl_group(hl.sign) + .sign_text(sign_text) + .build(); + + buf.set_extmark(get_namespace().into(), line, col, set_opts) + .map(|id| Extmark::new(id, (line, col).into())) + .map_err(Into::::into) +} + +pub fn update_record_mark( + extmark: &Extmark, + mut buf: Buffer, + &CursorPosition { line, col }: &CursorPosition, + time: RecordMarkTime, +) -> Result<()> { + let sign_text: &SignText = time.into(); + + let (line, col) = get_non_blank_pos(buf.clone(), line.saturating_sub(1), col); + + let set_opts = { + let mut builder = SetExtmarkOpts::builder(); + + let builder = builder.id(Into::::into(extmark)); + let builder = basic_builder(builder, line, col); + + let hl: RecordHighlightNames = time.into(); + builder.hl_group(hl.mark); + builder.sign_hl_group(hl.sign); + builder.sign_text(sign_text); + + builder.build() + }; + + buf.set_extmark(get_namespace().into(), line, col, &set_opts) + .map(|_| ()) + .map_err(Into::into) +} + +// TODO: make hints scoped to a single window once the namespace API is stabilized +pub fn create_hint_mark( + mut buf: Buffer, + &CursorPosition { line, col }: &CursorPosition, + name: &str, + typ: GridLayout, +) -> Result<(Extmark, Option)> { + let hl = HintHighlightList::hint_hl_names(typ); + + let label = { + buf.set_extmark( + get_namespace().into(), + line.saturating_sub(1), + col, + &SetExtmarkOpts::builder() + .virt_text([(name, hl.mark)]) + .virt_text_pos(ExtmarkVirtTextPosition::Overlay) + .virt_text_hide(false) + .build(), + ) + .map(|id| Extmark::try_new(id, buf.clone())) + .map_err(Into::::into)? + .map_err(Into::::into)? + }; + + let path = { + let filename = &get_config().picker.filename; + match filename.enable { + true => Some( + buf.set_extmark( + get_namespace().into(), + line.saturating_sub(1), + 0, + &SetExtmarkOpts::builder() + .virt_text([( + truncate_path(&buf.get_name()?, filename.depth).as_path(), + hl.path, + )]) + .virt_text_pos(ExtmarkVirtTextPosition::Eol) + .virt_text_hide(false) + .build(), + ) + .map(|id| Extmark::try_new(id, buf)) + .map_err(Into::::into)? + .map_err(Into::::into)?, + ), + false => None, + } + }; + + Ok((label, path)) +} + +fn truncate_path(path: &Path, depth: usize) -> PathBuf { + let mut components = path + .components() + .rev() + .take(depth) + .collect::>(); + components.reverse(); + components.iter().collect() +} + +mod tests { + use super::*; + + #[nvim_oxi::test] + fn gets_first_non_blank_col() { + let mut buf = Buffer::current(); + buf.set_lines( + .., + false, + [ + "\t\topener line", + "\t\t processed line ", + " finish line", + ], + ) + .unwrap(); + + let got = get_non_blank_pos(buf, 1, 0); + + assert_eq!(got, (1, 6)); + } + + #[nvim_oxi::test] + fn get_first_non_blank_line_and_col_downside() { + let mut buf = Buffer::current(); + buf.set_lines( + .., + false, + [ + "\t\topener line", + "", + "", // processed line + "", + "\t\texpected line", + "\t\t", + ], + ) + .unwrap(); + + let got = get_non_blank_pos(buf, 2, 0); + + assert_eq!(got, (4, 2)); + } + + #[nvim_oxi::test] + fn get_first_non_blank_line_and_col_upside() { + let mut buf = Buffer::current(); + buf.set_lines( + .., + false, + [ + "\t\topener line", + "\t\texpected line", + "\t\t", + "", // processed line + "", + "\t\t", + "", + ], + ) + .unwrap(); + + let got = get_non_blank_pos(buf, 3, 0); + + assert_eq!(got, (1, 2)); + } + + #[nvim_oxi::test] + fn recreates_mark_track_respecting_pos_middle() { + let pos = Some(1); + + let r = 0..4; + let mut hl_vec = Vec::new(); + for i in r { + hl_vec.push(recreate_mark_time(i, pos)); + } + + let want = Vec::from([ + RecordMarkTime::Future, + RecordMarkTime::FutureClose, + RecordMarkTime::PastClose, + RecordMarkTime::Past, + ]); + assert_eq!(hl_vec, want); + } + + #[nvim_oxi::test] + fn recreates_mark_track_respecting_pos_start() { + let pos = None; + + let r = 0..4; + let mut hl_vec = Vec::new(); + for i in r { + hl_vec.push(recreate_mark_time(i, pos)); + } + + let want = Vec::from([ + RecordMarkTime::PastClose, + RecordMarkTime::Past, + RecordMarkTime::Past, + RecordMarkTime::Past, + ]); + assert_eq!(hl_vec, want); + } +} diff --git a/compass/src/ui/tab.rs b/compass/src/ui/tab.rs new file mode 100644 index 0000000..6227bb7 --- /dev/null +++ b/compass/src/ui/tab.rs @@ -0,0 +1,25 @@ +use crate::Result; + +pub fn open_tab() -> Result<()> { + Ok(nvim_oxi::api::command("tabnew")?) +} + +pub fn close_tab() -> Result<()> { + Ok(nvim_oxi::api::command("tabclose")?) +} + +mod tests { + use super::*; + use nvim_oxi::api::get_current_tabpage; + + #[nvim_oxi::test] + fn can_open_tab() { + let old_tab = get_current_tabpage().get_number().unwrap(); + assert_eq!(old_tab, 1); + + open_tab().unwrap(); + + let new_tab = get_current_tabpage().get_number().unwrap(); + assert_eq!(new_tab, 2); + } +} diff --git a/compass/src/viml/compass_args.rs b/compass/src/viml/compass_args.rs new file mode 100644 index 0000000..1d5a75d --- /dev/null +++ b/compass/src/viml/compass_args.rs @@ -0,0 +1,85 @@ +use crate::{Error, InputError, Result, VimlError}; +use std::collections::HashMap; + +pub struct CompassArgs<'a> { + pub main_cmd: &'a str, + pub sub_cmds: Vec<&'a str>, + pub map_args: HashMap<&'a str, &'a str>, +} + +impl<'a> TryFrom> for CompassArgs<'a> { + type Error = Error; + + fn try_from(value: Vec<&'a str>) -> Result { + let mut iter = value.iter().peekable(); + + let main_cmd = iter + .next() + .ok_or(InputError::Viml(VimlError::InvalidCommand(format!( + "no main command specified in: {:?}", + value + ))))?; + + let mut sub_cmds: Vec<&str> = Vec::new(); + while let Some(s) = iter.peek() { + if s.contains('=') { + break; + } + + // We know it is Some because of the outer peek + sub_cmds.push(iter.next().unwrap()); + } + + let map_args = iter + .map(|s| -> Option<(&str, &str)> { s.split_once('=') }) + .collect::>>() + .ok_or(InputError::Viml(VimlError::InvalidCommand(format!( + "nothing found after = sign in: {:?}", + value, + ))))?; + + Ok(Self { + main_cmd, + sub_cmds, + map_args, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn can_parse_basic_command() { + let cmd: Vec<&str> = vec!["goto", "relative", "direction=forward", "some_id=42"]; + + let got = CompassArgs::try_from(cmd).unwrap(); + + assert_eq!(got.main_cmd, "goto".to_owned()); + assert_eq!(*got.sub_cmds.first().unwrap(), "relative"); + + let mut want_map = HashMap::new(); + want_map.insert("direction", "forward"); + want_map.insert("some_id", "42"); + assert_eq!(got.map_args, want_map) + } + + #[test] + fn can_parse_dictionary_command() { + let cmd: Vec<&str> = vec![ + "find_files", + "hidden=true", + r#"layout_config={"prompt_position":"top"}"#, + ]; + + let got = CompassArgs::try_from(cmd).unwrap(); + + assert_eq!(got.main_cmd, "find_files".to_owned()); + + let mut want_map = HashMap::new(); + want_map.insert("hidden", "true"); + want_map.insert("layout_config", r#"{"prompt_position":"top"}"#); + assert_eq!(got.map_args, want_map) + } +} diff --git a/compass/src/viml/mod.rs b/compass/src/viml/mod.rs new file mode 100644 index 0000000..224fbe3 --- /dev/null +++ b/compass/src/viml/mod.rs @@ -0,0 +1,2 @@ +mod compass_args; +pub use compass_args::CompassArgs; diff --git a/doc/compass.nvim.txt b/doc/compass.nvim.txt new file mode 100644 index 0000000..e9f62df --- /dev/null +++ b/doc/compass.nvim.txt @@ -0,0 +1,228 @@ +*compass.nvim.txt* For Neovim >= 0.8.0 Last change: 2024 June 25 + +============================================================================== +Table of Contents *compass.nvim-table-of-contents* + + - Requirements |compass.nvim-requirements| + - Installation |compass.nvim-installation| + - Keymaps |compass.nvim-keymaps| + - Configuration |compass.nvim-configuration| + - Highlights |compass.nvim-highlights| + +REQUIREMENTS *compass.nvim-requirements* + +- **Linux x86_64**, **MacOS ARM64/x86_64** or **Windows x86_64**. +- Neovim **v0.10.0** or **nightly**. Earlier versions are unlikely to work. + + +INSTALLATION *compass.nvim-installation* + + +LAZY.NVIM ~ + +>lua + { + "myypo/compass.nvim", + build = "make", + event = "BufReadPost", + } +< + +The plugin uses nvim-oxi to use Rust as the +plugin language. + +The provided above installation snippet will download a pre-built by GitHub +action library which should work out of the box, but you can also build it +yourself, provided, you have Rust toolchain installed. To do so just change +`build = "make"` to `build = "make build"`. + + +KEYMAPS *compass.nvim-keymaps* + +Example useful keymaps for **Lazy.nvim** + +>lua + keys = { + -- Choose between previous locations in all buffers to where to jump to + { "", "Compass open" }, + + -- Choose between locations in the current buffer to jump to + { "", "Compass follow buf" }, + + -- Move back to the most recent created/updated location + { "", "Compass goto relative direction=back" }, + -- Move forward to the next location + { "", "Compass goto relative direction=forward" }, + + -- Like goto but also deletes that plugin mark + { "", "Compass pop relative direction=back" }, + { "", "Compass pop relative direction=forward" }, + + -- Manually place a change mark that works the same way as automatically put ones + { "", "Compass place change" }, + -- Same as above but always creates a new mark instead of trying to update a nearby one + { "", "Compass place change try_update=false" }, + }, +< + + +CONFIGURATION *compass.nvim-configuration* + +Default configuration: + +>lua + { + -- Options for customizing the UI that allows to preview and jump to one of the plugin marks + picker = { + max_windows = 6, -- Limit of windows to be opened, must be an even number + + -- List of keys to be used to jump to a plugin mark + -- Length of this table must be equal or bigger than `max_windows` + jump_keys = { + -- First key is for previewing more jump marks in the file + -- Second key is to immediately jump to this mark + { "j", "J" }, + { "f", "F" }, + { "k", "K" }, + { "d", "D" }, + { "l", "L" }, + { "s", "S" }, + { ";", ":" }, + { "a", "A" }, + { "h", "H" }, + { "g", "G" }, + }, + + filename = { + enable = true, -- Whether to preview filename of the buffer next to the picker hint + depth = 2, -- How many components of the path to show, `2` only shows the filename and the name of the parent directory + }, + }, + + -- Options for the plugin marks + marks = { + -- When applicable, how close an old plugin mark has to be to the newly placed one + -- for the old one to be moved to the new position instead of actually creating a new seperate mark + -- Absence of a defined value means that the condition will always evaluate to false + update_range = { + lines = { + single_max_distance = 10, -- If closer than this number of lines update the existing mark + -- If closer than this number of lines AND `columns.combined_max_distance` is closer + -- than its respective number of columns update the existing mark + combined_max_distance = 25, + }, + columns = { + single_max_distance = nil, -- If closer than this number of columns update the existing mark + combined_max_distance = 25, + }, + }, + + -- Which signs to use for different mark types relatively to the current position + signs = { + past = "●", + close_past = "●", + future = "●", + close_future = "●", + }, + }, + + -- Customization of the tracker automatically placing plugin marks on file edits etc. + tracker = { + enable = true, + }, + + -- Plugin state persistence options + persistence = { + enable = true, -- Whether to persist the plugin state on the disk + path = nil, -- Absolute path to where to persist the state, by default it assumes the default neovim share path + }, + + -- Weights and options for the frecency algorithm + -- When appropriate tries to prioritize showing most used and most recently used plugin marks, for example, in a picker UI + frecency = { + time_bucket = { + -- This table can be of any length + thresholds = { + { hours = 4, weight = 100 }, + { hours = 14, weight = 70 }, + { hours = 31, weight = 50 }, + { hours = 90, weight = 30 }, + } + fallback = 10, -- Default weight when a value is older than the biggest `hours` in `thresholds` + }, + -- Weights for different types of interaction with the mark + visit_type = { + create = 50, + update = 100, + relative_goto = 50, + absolute_goto = 100, + }, + -- Interactions that happen earlier than the cooldown won't be taken into accont when calculating marks' weights + cooldown_seconds = 60, + }, + } +< + + +HIGHLIGHTS *compass.nvim-highlights* + +The plugin defines highlights that can be overwritten by colorschemes or +manually with: `vim.api.nvim_set_hl` + +Marks down the stack ~ + +HighlightDefaultCompassRecordPast> + guibg=grey gui=bold +< + +CompassRecordPastSign> + guibg=grey gui=bold +< + +CompassRecordClosePast> + guifg=red gui=bold +< + +CompassRecordClosePastSign> + guifg=red gui=bold +< + +Marks up the stack ~ + +HighlightDefaultCompassRecordFuture> + guibg=grey gui=bold +< + +CompassRecordFutureSign> + guibg=grey gui=bold +< + +CompassRecordCloseFuture> + guifg=blue gui=bold +< + +CompassRecordCloseFutureSign> + guifg=blue gui=bold +< + +Picker window for `open` and `follow` commands ~ + +HighlightDefaultCompassHintOpen> + guifg=black guibg=yellow gui=bold +< + +CompassHintOpenPath> + guifg=black gui=bold +< + +CompassHintFollow> + guifg=black guibg=yellow gui=bold +< + +CompassHintFollowPath> + guifg=black gui=bold +< + +Generated by panvimdoc + +vim:tw=78:ts=8:noet:ft=help:norl: diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..ab9cc30 --- /dev/null +++ b/flake.lock @@ -0,0 +1,286 @@ +{ + "nodes": { + "fenix": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ], + "rust-analyzer-src": "rust-analyzer-src" + }, + "locked": { + "lastModified": 1717223092, + "narHash": "sha256-ih8NPk3Jn5EAILOGQZ+KS5NLmu6QmwohJX+36MaTAQE=", + "owner": "nix-community", + "repo": "fenix", + "rev": "9a025daf6799e3af80b677f0af57ef76432c3fcf", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "fenix", + "type": "github" + } + }, + "flake-compat": { + "flake": false, + "locked": { + "lastModified": 1696426674, + "narHash": "sha256-kvjfFW7WAETZlt09AgDn1MrtKzP7t90Vf7vypd3OL1U=", + "owner": "edolstra", + "repo": "flake-compat", + "rev": "0f9255e01c2351cc7d116c072cb317785dd33b33", + "type": "github" + }, + "original": { + "owner": "edolstra", + "repo": "flake-compat", + "type": "github" + } + }, + "flake-compat_2": { + "flake": false, + "locked": { + "lastModified": 1696426674, + "narHash": "sha256-kvjfFW7WAETZlt09AgDn1MrtKzP7t90Vf7vypd3OL1U=", + "owner": "edolstra", + "repo": "flake-compat", + "rev": "0f9255e01c2351cc7d116c072cb317785dd33b33", + "type": "github" + }, + "original": { + "owner": "edolstra", + "repo": "flake-compat", + "type": "github" + } + }, + "flake-parts": { + "inputs": { + "nixpkgs-lib": [ + "neovim-nightly-overlay", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1715865404, + "narHash": "sha256-/GJvTdTpuDjNn84j82cU6bXztE0MSkdnTWClUCRub78=", + "owner": "hercules-ci", + "repo": "flake-parts", + "rev": "8dc45382d5206bd292f9c2768b8058a8fd8311d9", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "flake-parts", + "type": "github" + } + }, + "flake-parts_2": { + "inputs": { + "nixpkgs-lib": [ + "neovim-nightly-overlay", + "hercules-ci-effects", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1712014858, + "narHash": "sha256-sB4SWl2lX95bExY2gMFG5HIzvva5AVMJd4Igm+GpZNw=", + "owner": "hercules-ci", + "repo": "flake-parts", + "rev": "9126214d0a59633752a136528f5f3b9aa8565b7d", + "type": "github" + }, + "original": { + "id": "flake-parts", + "type": "indirect" + } + }, + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1710146030, + "narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "git-hooks": { + "inputs": { + "flake-compat": "flake-compat_2", + "gitignore": "gitignore", + "nixpkgs": [ + "neovim-nightly-overlay", + "nixpkgs" + ], + "nixpkgs-stable": [ + "neovim-nightly-overlay", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1716213921, + "narHash": "sha256-xrsYFST8ij4QWaV6HEokCUNIZLjjLP1bYC60K8XiBVA=", + "owner": "cachix", + "repo": "git-hooks.nix", + "rev": "0e8fcc54b842ad8428c9e705cb5994eaf05c26a0", + "type": "github" + }, + "original": { + "owner": "cachix", + "repo": "git-hooks.nix", + "type": "github" + } + }, + "gitignore": { + "inputs": { + "nixpkgs": [ + "neovim-nightly-overlay", + "git-hooks", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1709087332, + "narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=", + "owner": "hercules-ci", + "repo": "gitignore.nix", + "rev": "637db329424fd7e46cf4185293b9cc8c88c95394", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "gitignore.nix", + "type": "github" + } + }, + "hercules-ci-effects": { + "inputs": { + "flake-parts": "flake-parts_2", + "nixpkgs": [ + "neovim-nightly-overlay", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1713898448, + "narHash": "sha256-6q6ojsp/Z9P2goqnxyfCSzFOD92T3Uobmj8oVAicUOs=", + "owner": "hercules-ci", + "repo": "hercules-ci-effects", + "rev": "c0302ec12d569532a6b6bd218f698bc402e93adc", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "hercules-ci-effects", + "type": "github" + } + }, + "neovim-nightly-overlay": { + "inputs": { + "flake-compat": "flake-compat", + "flake-parts": "flake-parts", + "git-hooks": "git-hooks", + "hercules-ci-effects": "hercules-ci-effects", + "neovim-src": "neovim-src", + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1717214603, + "narHash": "sha256-GHZpwwZe7LVYCQGp05oFQ653oiP3jgin+bgZSOgp3uE=", + "owner": "nix-community", + "repo": "neovim-nightly-overlay", + "rev": "15fae73bcb20aad8fe2c88373d77a2b71dd13f5a", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "neovim-nightly-overlay", + "type": "github" + } + }, + "neovim-src": { + "flake": false, + "locked": { + "lastModified": 1717166885, + "narHash": "sha256-HcvLlqj4SaBEqjf1aVnH0Jig1oVwrX/LWNbAx0Sx5Jk=", + "owner": "neovim", + "repo": "neovim", + "rev": "d62d181ce065556be51d5eda0425aa42f427cc27", + "type": "github" + }, + "original": { + "owner": "neovim", + "repo": "neovim", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1716948383, + "narHash": "sha256-SzDKxseEcHR5KzPXLwsemyTR/kaM9whxeiJohbL04rs=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "ad57eef4ef0659193044870c731987a6df5cf56b", + "type": "github" + }, + "original": { + "id": "nixpkgs", + "ref": "nixos-unstable", + "type": "indirect" + } + }, + "root": { + "inputs": { + "fenix": "fenix", + "flake-utils": "flake-utils", + "neovim-nightly-overlay": "neovim-nightly-overlay", + "nixpkgs": "nixpkgs" + } + }, + "rust-analyzer-src": { + "flake": false, + "locked": { + "lastModified": 1717169693, + "narHash": "sha256-qBruki5NHrSqIw5ulxtwFmVsb6W/aOKOMjsCJjfalA4=", + "owner": "rust-lang", + "repo": "rust-analyzer", + "rev": "d6d735e6f20ef78b16a79886fe28bd69cf059504", + "type": "github" + }, + "original": { + "owner": "rust-lang", + "ref": "nightly", + "repo": "rust-analyzer", + "type": "github" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..298e6a5 --- /dev/null +++ b/flake.nix @@ -0,0 +1,71 @@ +{ + inputs = { + nixpkgs.url = "nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + + neovim-nightly-overlay.url = "github:nix-community/neovim-nightly-overlay"; + neovim-nightly-overlay.inputs.nixpkgs.follows = "nixpkgs"; + + fenix.url = "github:nix-community/fenix"; + fenix.inputs.nixpkgs.follows = "nixpkgs"; + }; + + outputs = inputs: + with inputs; + flake-utils.lib.eachDefaultSystem ( + system: let + overlays = [fenix.overlays.default neovim-nightly-overlay.overlays.default]; + pkgs = import nixpkgs { + inherit overlays system; + }; + in { + packages = with pkgs; { + default = rustPlatform.buildRustPackage { + name = "compass"; + src = lib.cleanSource ./.; + cargoLock = { + lockFile = ./Cargo.lock; + allowBuiltinFetchGit = true; + }; + + doCheck = false; + + nativeBuildInputs = [ + pkg-config + rustPlatform.bindgenHook + ]; + }; + }; + + devShells = with pkgs; { + default = + mkShell.override { + stdenv = stdenvAdapters.useMoldLinker clangStdenv; + } + mkShell { + packages = [ + openssl + pkg-config + + neovim + + rust-analyzer-nightly + + rustPlatform.bindgenHook + + (fenix.complete.withComponents [ + "cargo" + "clippy" + "rust-src" + "rust-std" + "rustc" + "rustfmt" + ]) + + cargo-watch + ]; + }; + }; + } + ); +} diff --git a/lua/.gitkeep b/lua/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/macros/Cargo.toml b/macros/Cargo.toml new file mode 100644 index 0000000..aaf4bbe --- /dev/null +++ b/macros/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "macros" +edition = "2021" +publish = false + +[lib] +proc-macro = true + +[dependencies] +proc-macro2 = "1.0.82" +quote = "1.0.36" +syn = "2.0.63" diff --git a/macros/src/lib.rs b/macros/src/lib.rs new file mode 100644 index 0000000..6993fbc --- /dev/null +++ b/macros/src/lib.rs @@ -0,0 +1,109 @@ +use std::fs; + +use proc_macro::TokenStream; +use proc_macro2::Span; +use quote::{quote, ToTokens}; +use syn::{parse_macro_input, DeriveInput, Ident}; + +#[proc_macro_derive(FromLua)] +pub fn derive_deserializing_from_lua(input: TokenStream) -> TokenStream { + let DeriveInput { ident, .. } = parse_macro_input!(input as DeriveInput); + + quote! { + use nvim_oxi::conversion::FromObject; + + impl nvim_oxi::conversion::FromObject for #ident { + fn from_object(obj: nvim_oxi::Object) -> core::result::Result { + Self::deserialize(nvim_oxi::serde::Deserializer::new(obj)).map_err(Into::into) + } + } + + impl nvim_oxi::lua::Poppable for #ident { + unsafe fn pop(lstate: *mut nvim_oxi::lua::ffi::lua_State) -> core::result::Result { + let obj = nvim_oxi::Object::pop(lstate)?; + Self::from_object(obj).map_err(|e| nvim_oxi::lua::Error::RuntimeError(e.to_string())) + } + } + } + .into() +} + +#[proc_macro_derive(ToLua)] +pub fn derive_serializing_to_lua(input: TokenStream) -> TokenStream { + let DeriveInput { ident, .. } = parse_macro_input!(input as DeriveInput); + + quote! { + use nvim_oxi::conversion::ToObject; + + impl nvim_oxi::conversion::ToObject for #ident { + fn to_object(self) -> core::result::Result { + self.serialize(nvim_oxi::serde::Serializer::new()).map_err(Into::into) + } + } + + impl nvim_oxi::lua::Pushable for #ident { + unsafe fn push(self, lstate: *mut nvim_oxi::lua::ffi::lua_State) -> core::result::Result { + self.to_object() + .map_err(nvim_oxi::lua::Error::push_error_from_err::)? + .push(lstate) + } + } + } + .into() +} + +#[proc_macro] +pub fn functions_and_commands(input: TokenStream) -> TokenStream { + let path = input.to_string(); + + let manifest_dir = + std::env::var("CARGO_MANIFEST_DIR").expect("Failed to get Cargo manifest directory"); + let mut macro_call_path = + fs::canonicalize(manifest_dir).expect("Failed to canonicalize Cargo manifest directory"); + macro_call_path.push(path.trim_matches('"')); + + let all_modules = fs::read_dir(macro_call_path) + .expect("Failed to read directory") + .filter_map(|entry| { + if let Ok(entry) = entry { + if let Some(entry_name) = entry.file_name().to_str() { + if entry_name != "mod.rs" && entry_name != "setup.rs" { + return Some(entry_name.to_owned()); + } + } + } + None + }); + + let all_modules: Vec = all_modules + .map(|mut m| { + m.get_mut(0..1) + .expect("Module must have a name") + .make_ascii_uppercase(); + + m + }) + .collect(); + + let external: Vec = all_modules + .clone() + .into_iter() + .map(|m| Ident::new(&m, Span::call_site())) + .collect(); + + quote! { + #[derive(strum_macros::VariantNames, strum_macros::EnumString, strum_macros::Display, strum_macros::EnumIter)] + #[strum(serialize_all = "lowercase")] + pub enum CommandNames { + #(#external),* + } + } + .into() +} + +#[proc_macro] +pub fn to_lowercase(input: TokenStream) -> TokenStream { + Ident::new(&input.to_string().to_lowercase(), Span::call_site()) + .into_token_stream() + .into() +}