From c65afb488eb9eab85063d79783d40ae1d7138586 Mon Sep 17 00:00:00 2001 From: Mike Vink Date: Sun, 19 Jan 2025 13:52:31 +0100 Subject: Squashed 'mut/neovim/pack/plugins/start/quicker.nvim/' content from commit 049def7 git-subtree-dir: mut/neovim/pack/plugins/start/quicker.nvim git-subtree-split: 049def718213d3cdf49fdf29835aded09b3e54a3 --- .envrc | 3 + .github/ISSUE_TEMPLATE/bug_report.yml | 116 ++++ .github/ISSUE_TEMPLATE/feature_request.yml | 43 ++ .github/pre-commit | 3 + .github/pre-push | 11 + ...automation_remove_question_label_on_comment.yml | 16 + .github/workflows/automation_request_review.yml | 27 + .github/workflows/install_nvim.sh | 12 + .github/workflows/tests.yml | 122 +++++ .gitignore | 48 ++ .luacheckrc | 17 + .luarc.json | 9 + .stylua.toml | 5 + CHANGELOG.md | 65 +++ LICENSE | 21 + Makefile | 52 ++ README.md | 375 +++++++++++++ doc/quicker.txt | 181 +++++++ lua/quicker/config.lua | 189 +++++++ lua/quicker/context.lua | 315 +++++++++++ lua/quicker/cursor.lua | 44 ++ lua/quicker/display.lua | 601 +++++++++++++++++++++ lua/quicker/editor.lua | 405 ++++++++++++++ lua/quicker/follow.lua | 84 +++ lua/quicker/fs.lua | 98 ++++ lua/quicker/highlight.lua | 222 ++++++++ lua/quicker/init.lua | 189 +++++++ lua/quicker/keys.lua | 20 + lua/quicker/opts.lua | 61 +++ lua/quicker/util.lua | 95 ++++ run_tests.sh | 36 ++ scripts/generate.py | 102 ++++ scripts/main.py | 31 ++ scripts/requirements.txt | 4 + syntax/qf.vim | 7 + tests/context_spec.lua | 134 +++++ tests/display_spec.lua | 145 +++++ tests/editor_spec.lua | 347 ++++++++++++ tests/fs_spec.lua | 20 + tests/minimal_init.lua | 16 + tests/opts_spec.lua | 52 ++ tests/snapshots/display_1 | 5 + tests/snapshots/display_long_1 | 1 + tests/snapshots/display_minimal_1 | 1 + tests/snapshots/edit_1 | 10 + tests/snapshots/edit_all_whitespace | 4 + tests/snapshots/edit_all_whitespace_qf | 2 + tests/snapshots/edit_delim | 10 + tests/snapshots/edit_dupe | 10 + tests/snapshots/edit_dupe_2 | 10 + tests/snapshots/edit_dupe_qf | 2 + tests/snapshots/edit_dupe_qf_2 | 2 + tests/snapshots/edit_expanded | 10 + tests/snapshots/edit_expanded_qf | 4 + tests/snapshots/edit_fail | 10 + tests/snapshots/edit_fail_qf | 1 + tests/snapshots/edit_invalid | 1 + tests/snapshots/edit_ll | 10 + tests/snapshots/edit_multiple_1 | 10 + tests/snapshots/edit_multiple_2 | 10 + tests/snapshots/edit_multiple_qf | 15 + tests/snapshots/edit_none_whitespace | 4 + tests/snapshots/edit_none_whitespace_qf | 2 + tests/snapshots/edit_whitespace | 4 + tests/snapshots/edit_whitespace_qf | 2 + tests/snapshots/expand_1 | 3 + tests/snapshots/expand_2 | 16 + tests/snapshots/expand_3 | 19 + tests/snapshots/expand_dupe_1 | 3 + tests/snapshots/expand_dupe_2 | 5 + tests/snapshots/expand_loclist | 4 + tests/snapshots/expand_missing | 4 + tests/snapshots/trim_all_whitespace | 2 + tests/snapshots/trim_mixed_whitespace | 2 + tests/snapshots/trim_whitespace | 2 + tests/snapshots/trim_whitespace_expanded | 5 + tests/test_util.lua | 142 +++++ tests/whitespace_spec.lua | 83 +++ 78 files changed, 4773 insertions(+) create mode 100644 .envrc create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml create mode 100755 .github/pre-commit create mode 100755 .github/pre-push create mode 100644 .github/workflows/automation_remove_question_label_on_comment.yml create mode 100644 .github/workflows/automation_request_review.yml create mode 100644 .github/workflows/install_nvim.sh create mode 100644 .github/workflows/tests.yml create mode 100644 .gitignore create mode 100644 .luacheckrc create mode 100644 .luarc.json create mode 100644 .stylua.toml create mode 100644 CHANGELOG.md create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 doc/quicker.txt create mode 100644 lua/quicker/config.lua create mode 100644 lua/quicker/context.lua create mode 100644 lua/quicker/cursor.lua create mode 100644 lua/quicker/display.lua create mode 100644 lua/quicker/editor.lua create mode 100644 lua/quicker/follow.lua create mode 100644 lua/quicker/fs.lua create mode 100644 lua/quicker/highlight.lua create mode 100644 lua/quicker/init.lua create mode 100644 lua/quicker/keys.lua create mode 100644 lua/quicker/opts.lua create mode 100644 lua/quicker/util.lua create mode 100755 run_tests.sh create mode 100755 scripts/generate.py create mode 100755 scripts/main.py create mode 100644 scripts/requirements.txt create mode 100644 syntax/qf.vim create mode 100644 tests/context_spec.lua create mode 100644 tests/display_spec.lua create mode 100644 tests/editor_spec.lua create mode 100644 tests/fs_spec.lua create mode 100644 tests/minimal_init.lua create mode 100644 tests/opts_spec.lua create mode 100644 tests/snapshots/display_1 create mode 100644 tests/snapshots/display_long_1 create mode 100644 tests/snapshots/display_minimal_1 create mode 100644 tests/snapshots/edit_1 create mode 100644 tests/snapshots/edit_all_whitespace create mode 100644 tests/snapshots/edit_all_whitespace_qf create mode 100644 tests/snapshots/edit_delim create mode 100644 tests/snapshots/edit_dupe create mode 100644 tests/snapshots/edit_dupe_2 create mode 100644 tests/snapshots/edit_dupe_qf create mode 100644 tests/snapshots/edit_dupe_qf_2 create mode 100644 tests/snapshots/edit_expanded create mode 100644 tests/snapshots/edit_expanded_qf create mode 100644 tests/snapshots/edit_fail create mode 100644 tests/snapshots/edit_fail_qf create mode 100644 tests/snapshots/edit_invalid create mode 100644 tests/snapshots/edit_ll create mode 100644 tests/snapshots/edit_multiple_1 create mode 100644 tests/snapshots/edit_multiple_2 create mode 100644 tests/snapshots/edit_multiple_qf create mode 100644 tests/snapshots/edit_none_whitespace create mode 100644 tests/snapshots/edit_none_whitespace_qf create mode 100644 tests/snapshots/edit_whitespace create mode 100644 tests/snapshots/edit_whitespace_qf create mode 100644 tests/snapshots/expand_1 create mode 100644 tests/snapshots/expand_2 create mode 100644 tests/snapshots/expand_3 create mode 100644 tests/snapshots/expand_dupe_1 create mode 100644 tests/snapshots/expand_dupe_2 create mode 100644 tests/snapshots/expand_loclist create mode 100644 tests/snapshots/expand_missing create mode 100644 tests/snapshots/trim_all_whitespace create mode 100644 tests/snapshots/trim_mixed_whitespace create mode 100644 tests/snapshots/trim_whitespace create mode 100644 tests/snapshots/trim_whitespace_expanded create mode 100644 tests/test_util.lua create mode 100644 tests/whitespace_spec.lua diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..94b55e4 --- /dev/null +++ b/.envrc @@ -0,0 +1,3 @@ +export VIRTUAL_ENV=venv +layout python +python -c 'import pyparsing' 2> /dev/null || pip install -r scripts/requirements.txt diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..1fcb4d8 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,116 @@ +name: Bug Report +description: File a bug/issue +title: "bug: " +labels: [bug] +body: + - type: markdown + attributes: + value: | + Before reporting a bug, make sure to search [existing issues](https://github.com/stevearc/quicker.nvim/issues) + - type: input + attributes: + label: "Neovim version (nvim -v)" + placeholder: "0.10.0 commit db1b0ee3b30f" + validations: + required: true + - type: input + attributes: + label: "Operating system/version" + placeholder: "MacOS 11.5" + validations: + required: true + - type: textarea + attributes: + label: Describe the bug + description: A clear and concise description of what the bug is. + validations: + required: true + - type: dropdown + attributes: + label: What is the severity of this bug? + options: + - minor (annoyance) + - tolerable (can work around it) + - breaking (some functionality is broken) + - blocking (cannot use plugin) + validations: + required: true + - type: textarea + attributes: + label: Steps To Reproduce + description: Steps to reproduce the behavior. + placeholder: | + 1. nvim -u repro.lua + 2. + 3. + validations: + required: true + - type: textarea + attributes: + label: Expected Behavior + description: A concise description of what you expected to happen. + validations: + required: true + - type: textarea + attributes: + label: Minimal example file + description: A small example file you are editing that produces the issue + validations: + required: false + - type: textarea + attributes: + label: Minimal init.lua + description: + Minimal `init.lua` to reproduce this issue. Save as `repro.lua` and run with `nvim -u repro.lua` + This uses lazy.nvim (a plugin manager). + value: | + -- DO NOT change the paths and don't remove the colorscheme + local root = vim.fn.fnamemodify("./.repro", ":p") + + -- set stdpaths to use .repro + for _, name in ipairs({ "config", "data", "state", "cache" }) do + vim.env[("XDG_%s_HOME"):format(name:upper())] = root .. "/" .. name + end + + -- bootstrap lazy + local lazypath = root .. "/plugins/lazy.nvim" + if not vim.loop.fs_stat(lazypath) then + vim.fn.system({ + "git", + "clone", + "--filter=blob:none", + "--single-branch", + "https://github.com/folke/lazy.nvim.git", + lazypath, + }) + end + vim.opt.runtimepath:prepend(lazypath) + + -- install plugins + local plugins = { + "folke/tokyonight.nvim", + { + "stevearc/quicker.nvim", + config = function() + require("quicker").setup({ + -- add your config here + }) + end, + }, + -- add any other plugins here + } + require("lazy").setup(plugins, { + root = root .. "/plugins", + }) + + vim.cmd.colorscheme("tokyonight") + -- add anything else here + render: Lua + validations: + required: false + - type: textarea + attributes: + label: Additional context + description: Any additional information or screenshots you would like to provide + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..f735c8c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,43 @@ +name: Feature Request +description: Submit a feature request +title: "feature request: " +labels: [enhancement] +body: + - type: markdown + attributes: + value: | + Before submitting a feature request, make sure to search for [existing requests](https://github.com/stevearc/quicker.nvim/issues) + - type: checkboxes + attributes: + label: Did you check existing requests? + options: + - label: I have searched the existing issues + required: true + - type: textarea + attributes: + label: Describe the feature + description: A short summary of the feature you want + validations: + required: true + - type: textarea + attributes: + label: Provide background + description: Describe the reasoning behind why you want the feature. + placeholder: I am trying to do X. My current workflow is Y. + validations: + required: false + - type: dropdown + attributes: + label: What is the significance of this feature? + options: + - nice to have + - strongly desired + - cannot use this plugin without it + validations: + required: true + - type: textarea + attributes: + label: Additional details + description: Any additional information you would like to provide. Things you've tried, alternatives considered, examples from other plugins, etc. + validations: + required: false diff --git a/.github/pre-commit b/.github/pre-commit new file mode 100755 index 0000000..c64fbec --- /dev/null +++ b/.github/pre-commit @@ -0,0 +1,3 @@ +#!/bin/bash +set -e +make fastlint diff --git a/.github/pre-push b/.github/pre-push new file mode 100755 index 0000000..ecb23a9 --- /dev/null +++ b/.github/pre-push @@ -0,0 +1,11 @@ +#!/bin/bash +set -e +IFS=' ' +while read local_ref _local_sha _remote_ref _remote_sha; do + remote_main=$( (git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null || echo "///master") | cut -f 4 -d / | tr -d "[:space:]") + local_ref_short=$(echo "$local_ref" | cut -f 3 -d / | tr -d "[:space:]") + if [ "$local_ref_short" = "$remote_main" ]; then + make lint + make test + fi +done diff --git a/.github/workflows/automation_remove_question_label_on_comment.yml b/.github/workflows/automation_remove_question_label_on_comment.yml new file mode 100644 index 0000000..f99bba8 --- /dev/null +++ b/.github/workflows/automation_remove_question_label_on_comment.yml @@ -0,0 +1,16 @@ +name: Remove Question Label on Issue Comment + +on: [issue_comment] + +jobs: + # Remove the "question" label when a new comment is added. + # This lets me ask a question, tag the issue with "question", and filter out all "question"-tagged + # issues in my "needs triage" filter. + remove_question: + runs-on: ubuntu-latest + if: github.event.sender.login != 'stevearc' + steps: + - uses: actions/checkout@v4 + - uses: actions-ecosystem/action-remove-labels@v1 + with: + labels: question diff --git a/.github/workflows/automation_request_review.yml b/.github/workflows/automation_request_review.yml new file mode 100644 index 0000000..c31f582 --- /dev/null +++ b/.github/workflows/automation_request_review.yml @@ -0,0 +1,27 @@ +name: Request Review +permissions: + pull-requests: write +on: + pull_request_target: + types: [opened, reopened, ready_for_review, synchronize] + branches-ignore: + - "release-please--**" + +jobs: + # Request review automatically when PRs are opened + request_review: + runs-on: ubuntu-latest + steps: + - name: Request Review + uses: actions/github-script@v7 + if: github.actor != 'stevearc' + with: + github-token: ${{secrets.GITHUB_TOKEN}} + script: | + const pr = context.payload.pull_request; + github.rest.pulls.requestReviewers({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: pr.number, + reviewers: ['stevearc'] + }); diff --git a/.github/workflows/install_nvim.sh b/.github/workflows/install_nvim.sh new file mode 100644 index 0000000..4c0203c --- /dev/null +++ b/.github/workflows/install_nvim.sh @@ -0,0 +1,12 @@ +#!/bin/bash +set -e +PLUGINS="$HOME/.local/share/nvim/site/pack/plugins/start" +mkdir -p "$PLUGINS" + +wget "https://github.com/neovim/neovim/releases/download/${NVIM_TAG-stable}/nvim.appimage" +chmod +x nvim.appimage +./nvim.appimage --appimage-extract >/dev/null +rm -f nvim.appimage +mkdir -p ~/.local/share/nvim +mv squashfs-root ~/.local/share/nvim/appimage +sudo ln -s "$HOME/.local/share/nvim/appimage/AppRun" /usr/bin/nvim diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..a053f5d --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,122 @@ +name: Run tests + +on: + push: + branches: + - master + pull_request: + branches: + - master + +jobs: + luacheck: + name: Luacheck + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + + - name: Prepare + run: | + sudo apt-get update + sudo add-apt-repository universe + sudo apt install luarocks -y + sudo luarocks install luacheck + + - name: Run Luacheck + run: luacheck lua tests + + typecheck: + name: typecheck + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + - uses: stevearc/nvim-typecheck-action@v2 + with: + path: lua + + stylua: + name: StyLua + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + - name: Stylua + uses: JohnnyMorganz/stylua-action@v4 + with: + token: ${{ secrets.GITHUB_TOKEN }} + version: v0.20.0 + args: --check lua tests + + run_tests: + strategy: + matrix: + include: + - nvim_tag: v0.10.1 + + name: Run tests + runs-on: ubuntu-22.04 + env: + NVIM_TAG: ${{ matrix.nvim_tag }} + steps: + - uses: actions/checkout@v4 + + - name: Install Neovim and dependencies + run: | + bash ./.github/workflows/install_nvim.sh + + - name: Run tests + run: | + bash ./run_tests.sh + + update_docs: + name: Update docs + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + + - name: Install Neovim and dependencies + run: | + bash ./.github/workflows/install_nvim.sh + + - name: Update docs + run: | + python -m pip install pyparsing==3.0.9 + make doc + - name: Commit changes + if: ${{ github.ref == 'refs/heads/master' }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + COMMIT_MSG: | + [docgen] Update docs + skip-checks: true + run: | + git config user.email "actions@github" + git config user.name "Github Actions" + git remote set-url origin https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git + git add README.md doc + # Only commit and push if we have changes + git diff --quiet && git diff --staged --quiet || (git commit -m "${COMMIT_MSG}"; git push origin HEAD:${GITHUB_REF}) + + release: + name: release + + if: ${{ github.ref == 'refs/heads/master' }} + needs: + - luacheck + - stylua + - typecheck + - run_tests + - update_docs + runs-on: ubuntu-22.04 + steps: + - uses: googleapis/release-please-action@v4 + id: release + with: + release-type: simple + - uses: actions/checkout@v4 + - uses: rickstaa/action-create-tag@v1 + if: ${{ steps.release.outputs.release_created }} + with: + tag: stable + message: "Current stable release: ${{ steps.release.outputs.tag_name }}" + tag_exists_error: false + force_push_tag: true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9e6bcf7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,48 @@ +# Compiled Lua sources +luac.out + +# luarocks build files +*.src.rock +*.zip +*.tar.gz + +# Object files +*.o +*.os +*.ko +*.obj +*.elf + +# Precompiled Headers +*.gch +*.pch + +# Libraries +*.lib +*.a +*.la +*.lo +*.def +*.exp + +# Shared objects (inc. Windows DLLs) +*.dll +*.so +*.so.* +*.dylib + +# Executables +*.exe +*.out +*.app +*.i*86 +*.x86_64 +*.hex + +.direnv/ +.testenv/ +venv/ +doc/tags +scripts/nvim_doc_tools +scripts/nvim-typecheck-action +tests/tmp diff --git a/.luacheckrc b/.luacheckrc new file mode 100644 index 0000000..5e100b1 --- /dev/null +++ b/.luacheckrc @@ -0,0 +1,17 @@ +max_comment_line_length = false +codes = true + +exclude_files = { + "tests/", +} + +ignore = { + "212", -- Unused argument + "631", -- Line is too long + "122", -- Setting a readonly global + "542", -- Empty if branch +} + +read_globals = { + "vim", +} diff --git a/.luarc.json b/.luarc.json new file mode 100644 index 0000000..68da2f2 --- /dev/null +++ b/.luarc.json @@ -0,0 +1,9 @@ +{ + "runtime": { + "version": "LuaJIT", + "pathStrict": true + }, + "type": { + "checkTableShape": true + } +} diff --git a/.stylua.toml b/.stylua.toml new file mode 100644 index 0000000..020ce91 --- /dev/null +++ b/.stylua.toml @@ -0,0 +1,5 @@ +column_width = 100 +indent_type = "Spaces" +indent_width = 2 +[sort_requires] +enabled = true diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..da1f1e5 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,65 @@ +# Changelog + +## [1.3.0](https://github.com/stevearc/quicker.nvim/compare/v1.2.0...v1.3.0) (2024-12-24) + + +### Features + +* add option to remove all leading whitespace from items ([#26](https://github.com/stevearc/quicker.nvim/issues/26)) ([da7e910](https://github.com/stevearc/quicker.nvim/commit/da7e9104de4ff9303e1c722f7c9216f994622067)) +* option to scroll to closest quickfix item ([#23](https://github.com/stevearc/quicker.nvim/issues/23)) ([cc8bb67](https://github.com/stevearc/quicker.nvim/commit/cc8bb67271c093a089d205def9dd69a188c45ae1)) +* toggle function for context ([#18](https://github.com/stevearc/quicker.nvim/issues/18)) ([049d665](https://github.com/stevearc/quicker.nvim/commit/049d66534d3de5920663ee1b8dd0096d70f55a67)) + + +### Bug Fixes + +* filter vim.NIL when deserializing buffer variables ([#30](https://github.com/stevearc/quicker.nvim/issues/30)) ([a3cf525](https://github.com/stevearc/quicker.nvim/commit/a3cf5256998f9387ad8e293c6f295d286be6453f)) + +## [1.2.0](https://github.com/stevearc/quicker.nvim/compare/v1.1.1...v1.2.0) (2024-11-06) + + +### Features + +* add command modifiers to the `toggle()` and `open()` APIs ([#24](https://github.com/stevearc/quicker.nvim/issues/24)) ([95a839f](https://github.com/stevearc/quicker.nvim/commit/95a839fafff1c0a7fe970492f5159f41a90974bf)) + + +### Bug Fixes + +* crash in highlighter ([11f9eb0](https://github.com/stevearc/quicker.nvim/commit/11f9eb0c803bb9ced8c6043805de89c62bd04515)) +* guard against out of date buffer contents ([1fc29de](https://github.com/stevearc/quicker.nvim/commit/1fc29de2172235c076aa1ead6f1ee772398de732)) +* trim_leading_whitespace works with mixed tabs and spaces ([#26](https://github.com/stevearc/quicker.nvim/issues/26)) ([46e0ad6](https://github.com/stevearc/quicker.nvim/commit/46e0ad6c6a1d998a294e13cbb8b7c398e140983a)) + +## [1.1.1](https://github.com/stevearc/quicker.nvim/compare/v1.1.0...v1.1.1) (2024-08-20) + + +### Bug Fixes + +* refresh replaces all item text with buffer source ([f28fca3](https://github.com/stevearc/quicker.nvim/commit/f28fca3863f8d3679e86d8ff30d023a43fba15c8)) + +## [1.1.0](https://github.com/stevearc/quicker.nvim/compare/v1.0.0...v1.1.0) (2024-08-20) + + +### Features + +* better support for lazy loading ([29ab2a6](https://github.com/stevearc/quicker.nvim/commit/29ab2a6d4771ace240f25df028129bfc85e16ffd)) +* display errors as virtual text when expanding context ([#16](https://github.com/stevearc/quicker.nvim/issues/16)) ([6b79167](https://github.com/stevearc/quicker.nvim/commit/6b79167543f1b18e76319217a29bb4e177a5e1ae)) +* quicker.refresh preserves and display diagnostic messages ([#19](https://github.com/stevearc/quicker.nvim/issues/19)) ([349e0de](https://github.com/stevearc/quicker.nvim/commit/349e0def74ddbfc47f64ca52202e84bedf064048)) + + +### Bug Fixes + +* editor works when filename is truncated ([7a64d4e](https://github.com/stevearc/quicker.nvim/commit/7a64d4ea2b641cc8671443d0ff26de2924894c9f)) +* **editor:** load buffer if necessary before save_changes ([#14](https://github.com/stevearc/quicker.nvim/issues/14)) ([59a610a](https://github.com/stevearc/quicker.nvim/commit/59a610a2163a51a019bde769bf2e2eec1654e4d4)) +* error when quickfix buffer is hidden and items are added ([#8](https://github.com/stevearc/quicker.nvim/issues/8)) ([a8b885b](https://github.com/stevearc/quicker.nvim/commit/a8b885be246666922aca7f296195986a1cae3344)) +* guard against double-replacing a diagnostic line ([2dc0f80](https://github.com/stevearc/quicker.nvim/commit/2dc0f800770f8956c24a6d70fa61e7ec2e102d8a)) +* **highlight:** check if src_line exists before trying to highlight it ([#6](https://github.com/stevearc/quicker.nvim/issues/6)) ([b6a3d2f](https://github.com/stevearc/quicker.nvim/commit/b6a3d2f6aed7882e8bea772f82ba80b5535157a9)) +* include number of files in editor message ([#13](https://github.com/stevearc/quicker.nvim/issues/13)) ([7d2f6d3](https://github.com/stevearc/quicker.nvim/commit/7d2f6d33c7d680b0a18580cfa5feb17302f389d4)) +* missing highlight groups for headers ([5dafd80](https://github.com/stevearc/quicker.nvim/commit/5dafd80225ba462517c38e7b176bd3df52ccfb35)) +* prevent error when treesitter parser is missing ([#4](https://github.com/stevearc/quicker.nvim/issues/4)) ([5cc096a](https://github.com/stevearc/quicker.nvim/commit/5cc096aad4ba1c1e17b6d76cb87fd7155cf9a559)) +* show filename for invalid items ([#11](https://github.com/stevearc/quicker.nvim/issues/11)) ([514817d](https://github.com/stevearc/quicker.nvim/commit/514817dfb0a2828fe2c6183f996a31847c8aa789)) + +## 1.0.0 (2024-08-07) + + +### Bug Fixes + +* guard against race condition in syntax highlighting ([#1](https://github.com/stevearc/quicker.nvim/issues/1)) ([03d9811](https://github.com/stevearc/quicker.nvim/commit/03d9811c8ac037e4e9c8f4ba0dfd1dff0367e0ac)) diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ce6136c --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Steven Arcangeli + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..8643a8d --- /dev/null +++ b/Makefile @@ -0,0 +1,52 @@ +## help: print this help message +.PHONY: help +help: + @echo 'Usage:' + @sed -n 's/^##//p' ${MAKEFILE_LIST} | column -t -s ':' | sed -e 's/^/ /' + +## all: generate docs, lint, and run tests +.PHONY: all +all: doc lint test + +venv: + python3 -m venv venv + venv/bin/pip install -r scripts/requirements.txt + +## doc: generate documentation +.PHONY: doc +doc: scripts/nvim_doc_tools venv + venv/bin/python scripts/main.py generate + venv/bin/python scripts/main.py lint + +## test: run tests +.PHONY: test +test: + ./run_tests.sh + +## update_snapshots: Update the test snapshot files +.PHONY: update_snapshots +update_snapshots: + ./run_tests.sh --update + +## lint: run linters and LuaLS typechecking +.PHONY: lint +lint: scripts/nvim-typecheck-action fastlint + ./scripts/nvim-typecheck-action/typecheck.sh --workdir scripts/nvim-typecheck-action lua + +## fastlint: run only fast linters +.PHONY: fastlint +fastlint: scripts/nvim_doc_tools venv + venv/bin/python scripts/main.py lint + luacheck lua tests --formatter plain + stylua --check lua tests + +scripts/nvim_doc_tools: + git clone https://github.com/stevearc/nvim_doc_tools scripts/nvim_doc_tools + +scripts/nvim-typecheck-action: + git clone https://github.com/stevearc/nvim-typecheck-action scripts/nvim-typecheck-action + +## clean: reset the repository to a clean state +.PHONY: clean +clean: + rm -rf scripts/nvim_doc_tools scripts/nvim-typecheck-action venv .testenv diff --git a/README.md b/README.md new file mode 100644 index 0000000..6e07620 --- /dev/null +++ b/README.md @@ -0,0 +1,375 @@ +# quicker.nvim + +Improved UI and workflow for the Neovim quickfix + + + +- [Requirements](#requirements) +- [Features](#features) +- [Installation](#installation) +- [Setup](#setup) +- [Options](#options) +- [Highlights](#highlights) +- [API](#api) +- [Other Plugins](#other-plugins) + + + +## Requirements + +- Neovim 0.10+ + +## Features + +- **Improved styling** - including syntax highlighting of grep results. +- **Show context lines** - easily view lines above and below the quickfix results. +- **Editable buffer** - make changes across your whole project by editing the quickfix buffer and `:w`. +- **API helpers** - some helper methods for common tasks, such as toggling the quickfix. + +**Improved styling** (colorscheme: [Duskfox](https://github.com/EdenEast/nightfox.nvim/)) \ +Before \ +Screenshot 2024-07-30 at 6 03 39 PM + +After \ +Screenshot 2024-07-30 at 2 05 49 PM + +**Context lines** around the results \ +Screenshot 2024-07-30 at 2 06 17 PM + +**Editing the quickfix** to apply changes across multiple files + +https://github.com/user-attachments/assets/5065ac4d-ec24-49d1-a95d-232344b17484 + +## Installation + +quicker.nvim supports all the usual plugin managers + +
+ lazy.nvim + +```lua +{ + 'stevearc/quicker.nvim', + event = "FileType qf", + ---@module "quicker" + ---@type quicker.SetupOptions + opts = {}, +} +``` + +
+ +
+ Packer + +```lua +require("packer").startup(function() + use({ + "stevearc/quicker.nvim", + config = function() + require("quicker").setup() + end, + }) +end) +``` + +
+ +
+ Paq + +```lua +require("paq")({ + { "stevearc/quicker.nvim" }, +}) +``` + +
+ +
+ vim-plug + +```vim +Plug 'stevearc/quicker.nvim' +``` + +
+ +
+ dein + +```vim +call dein#add('stevearc/quicker.nvim') +``` + +
+ +
+ Pathogen + +```sh +git clone --depth=1 https://github.com/stevearc/quicker.nvim.git ~/.vim/bundle/ +``` + +
+ +
+ Neovim native package + +```sh +git clone --depth=1 https://github.com/stevearc/quicker.nvim.git \ + "${XDG_DATA_HOME:-$HOME/.local/share}"/nvim/site/pack/quicker/start/quicker.nvim +``` + +
+ +## Setup + +You will need to call `setup()` for quicker to start working + +```lua +require("quicker").setup() +``` + +It's not required to pass in any options, but you may wish to to set some keymaps. + +```lua +vim.keymap.set("n", "q", function() + require("quicker").toggle() +end, { + desc = "Toggle quickfix", +}) +vim.keymap.set("n", "l", function() + require("quicker").toggle({ loclist = true }) +end, { + desc = "Toggle loclist", +}) +require("quicker").setup({ + keys = { + { + ">", + function() + require("quicker").expand({ before = 2, after = 2, add_to_existing = true }) + end, + desc = "Expand quickfix context", + }, + { + "<", + function() + require("quicker").collapse() + end, + desc = "Collapse quickfix context", + }, + }, +}) +``` + +## Options + +A complete list of all configuration options + + +```lua +require("quicker").setup({ + -- Local options to set for quickfix + opts = { + buflisted = false, + number = false, + relativenumber = false, + signcolumn = "auto", + winfixheight = true, + wrap = false, + }, + -- Set to false to disable the default options in `opts` + use_default_opts = true, + -- Keymaps to set for the quickfix buffer + keys = { + -- { ">", "lua require('quicker').expand()", desc = "Expand quickfix content" }, + }, + -- Callback function to run any custom logic or keymaps for the quickfix buffer + on_qf = function(bufnr) end, + edit = { + -- Enable editing the quickfix like a normal buffer + enabled = true, + -- Set to true to write buffers after applying edits. + -- Set to "unmodified" to only write unmodified buffers. + autosave = "unmodified", + }, + -- Keep the cursor to the right of the filename and lnum columns + constrain_cursor = true, + highlight = { + -- Use treesitter highlighting + treesitter = true, + -- Use LSP semantic token highlighting + lsp = true, + -- Load the referenced buffers to apply more accurate highlights (may be slow) + load_buffers = true, + }, + follow = { + -- When quickfix window is open, scroll to closest item to the cursor + enabled = false, + }, + -- Map of quickfix item type to icon + type_icons = { + E = "󰅚 ", + W = "󰀪 ", + I = " ", + N = " ", + H = " ", + }, + -- Border characters + borders = { + vert = "┃", + -- Strong headers separate results from different files + strong_header = "━", + strong_cross = "╋", + strong_end = "┫", + -- Soft headers separate results within the same file + soft_header = "╌", + soft_cross = "╂", + soft_end = "┨", + }, + -- How to trim the leading whitespace from results. Can be 'all', 'common', or false + trim_leading_whitespace = "common", + -- Maximum width of the filename column + max_filename_width = function() + return math.floor(math.min(95, vim.o.columns / 2)) + end, + -- How far the header should extend to the right + header_length = function(type, start_col) + return vim.o.columns - start_col + end, +}) +``` + + + +## Highlights + +These are the highlight groups that are used to style the quickfix buffer. You can set these +highlight groups yourself or use `:help winhighlight` in the setup `opts` option to override them +for just the quickfix window. + +- `QuickFixHeaderHard` - Used for the header that divides results from different files +- `QuickFixHeaderSoft` - Used for the header that divides results within the same file +- `Delimiter` - Used for the divider between filename, line number, and text +- `QuickFixLineNr` - Used for the line number +- `QuickFixFilename` - Used for the filename +- `QuickFixFilenameInvalid` - Used for the filename when `valid = 0` +- `DiagnosticSign*` - Used for the signs that display the quickfix error type + +## API + + + +### expand(opts) + +`expand(opts)` \ +Expand the context around the quickfix results. + +| Param | Type | Desc | +| ---------------- | ------------------------- | -------------------------------------------------------------- | +| opts | `nil\|quicker.ExpandOpts` | | +| >before | `nil\|integer` | Number of lines of context to show before the line (default 2) | +| >after | `nil\|integer` | Number of lines of context to show after the line (default 2) | +| >add_to_existing | `nil\|boolean` | | +| >loclist_win | `nil\|integer` | | + +**Note:** +
+If there are multiple quickfix items for the same line of a file, only the first
+one will remain after calling expand().
+
+ +### collapse() + +`collapse()` \ +Collapse the context around quickfix results, leaving only the `valid` items. + + +### toggle_expand(opts) + +`toggle_expand(opts)` \ +Toggle the expanded context around the quickfix results. + +| Param | Type | Desc | +| ---------------- | ------------------------- | -------------------------------------------------------------- | +| opts | `nil\|quicker.ExpandOpts` | | +| >before | `nil\|integer` | Number of lines of context to show before the line (default 2) | +| >after | `nil\|integer` | Number of lines of context to show after the line (default 2) | +| >add_to_existing | `nil\|boolean` | | +| >loclist_win | `nil\|integer` | | + +### refresh(loclist_win, opts) + +`refresh(loclist_win, opts)` \ +Update the quickfix list with the current buffer text for each item. + +| Param | Type | Desc | +| ----------------- | -------------------------- | -------------------------------------------------------------------------------------------------------------------- | +| loclist_win | `nil\|integer` | | +| opts | `nil\|quicker.RefreshOpts` | | +| >keep_diagnostics | `nil\|boolean` | If a line has a diagnostic type, keep the original text and display it as virtual text after refreshing from source. | + +### is_open(loclist_win) + +`is_open(loclist_win)` + +| Param | Type | Desc | +| ----------- | -------------- | ---------------------------------------------------------------------- | +| loclist_win | `nil\|integer` | Check if loclist is open for the given window. If nil, check quickfix. | + +### toggle(opts) + +`toggle(opts)` \ +Toggle the quickfix or loclist window. + +| Param | Type | Desc | +| -------------- | -------------------------- | ----------------------------------------------------------------------------------- | +| opts | `nil\|quicker.OpenOpts` | | +| >loclist | `nil\|boolean` | Toggle the loclist instead of the quickfix list | +| >focus | `nil\|boolean` | Focus the quickfix window after toggling (default false) | +| >height | `nil\|integer` | Height of the quickfix window when opened. Defaults to number of items in the list. | +| >min_height | `nil\|integer` | Minimum height of the quickfix window. Default 4. | +| >max_height | `nil\|integer` | Maximum height of the quickfix window. Default 10. | +| >open_cmd_mods | `nil\|quicker.OpenCmdMods` | A table of modifiers for the quickfix or loclist open commands. | + +### open(opts) + +`open(opts)` \ +Open the quickfix or loclist window. + +| Param | Type | Desc | +| -------------- | -------------------------- | ----------------------------------------------------------------------------------- | +| opts | `nil\|quicker.OpenOpts` | | +| >loclist | `nil\|boolean` | Toggle the loclist instead of the quickfix list | +| >focus | `nil\|boolean` | Focus the quickfix window after toggling (default false) | +| >height | `nil\|integer` | Height of the quickfix window when opened. Defaults to number of items in the list. | +| >min_height | `nil\|integer` | Minimum height of the quickfix window. Default 4. | +| >max_height | `nil\|integer` | Maximum height of the quickfix window. Default 10. | +| >open_cmd_mods | `nil\|quicker.OpenCmdMods` | A table of modifiers for the quickfix or loclist open commands. | + +### close(opts) + +`close(opts)` \ +Close the quickfix or loclist window. + +| Param | Type | Desc | +| -------- | ------------------------ | ---------------------------------------------- | +| opts | `nil\|quicker.CloseOpts` | | +| >loclist | `nil\|boolean` | Close the loclist instead of the quickfix list | + + +## Other Plugins + +In general quicker.nvim should play nice with other quickfix plugins (🟢), except if they change the +format of the quickfix buffer. Quicker.nvim relies on owning the `:help quickfixtextfunc` for the +other features to function, so some other plugins you may need to disable or not use parts of their +functionality (🟡). Some plugins have features that completely conflict with quicker.nvim (🔴). + +- 🟢 [nvim-bqf](https://github.com/kevinhwang91/nvim-bqf) - Another bundle of several improvements including a floating preview window and fzf integration. +- 🟢 [vim-qf](https://github.com/romainl/vim-qf) - Adds some useful mappings and default behaviors. +- 🟡 [trouble.nvim](https://github.com/folke/trouble.nvim) - A custom UI for displaying quickfix and many other lists. Does not conflict with quicker.nvim, but instead presents an alternative way to manage and view the quickfix. +- 🟡 [listish.nvim](https://github.com/arsham/listish.nvim) - Provides utilities for adding items to the quickfix and theming (which conflicts with quicker.nvim). +- 🔴 [quickfix-reflector.vim](https://github.com/stefandtw/quickfix-reflector.vim) - Also provides an "editable quickfix". I used this for many years and would recommend it. +- 🔴 [replacer.nvim](https://github.com/gabrielpoca/replacer.nvim) - Another "editable quickfix" plugin. diff --git a/doc/quicker.txt b/doc/quicker.txt new file mode 100644 index 0000000..cf3dbe7 --- /dev/null +++ b/doc/quicker.txt @@ -0,0 +1,181 @@ +*quicker.txt* +*Quicker* *quicker* *quicker.nvim* +-------------------------------------------------------------------------------- +CONTENTS *quicker-contents* + + 1. Options |quicker-options| + 2. Api |quicker-api| + +-------------------------------------------------------------------------------- +OPTIONS *quicker-options* + +>lua + require("quicker").setup({ + -- Local options to set for quickfix + opts = { + buflisted = false, + number = false, + relativenumber = false, + signcolumn = "auto", + winfixheight = true, + wrap = false, + }, + -- Set to false to disable the default options in `opts` + use_default_opts = true, + -- Keymaps to set for the quickfix buffer + keys = { + -- { ">", "lua require('quicker').expand()", desc = "Expand quickfix content" }, + }, + -- Callback function to run any custom logic or keymaps for the quickfix buffer + on_qf = function(bufnr) end, + edit = { + -- Enable editing the quickfix like a normal buffer + enabled = true, + -- Set to true to write buffers after applying edits. + -- Set to "unmodified" to only write unmodified buffers. + autosave = "unmodified", + }, + -- Keep the cursor to the right of the filename and lnum columns + constrain_cursor = true, + highlight = { + -- Use treesitter highlighting + treesitter = true, + -- Use LSP semantic token highlighting + lsp = true, + -- Load the referenced buffers to apply more accurate highlights (may be slow) + load_buffers = true, + }, + follow = { + -- When quickfix window is open, scroll to closest item to the cursor + enabled = false, + }, + -- Map of quickfix item type to icon + type_icons = { + E = "󰅚 ", + W = "󰀪 ", + I = " ", + N = " ", + H = " ", + }, + -- Border characters + borders = { + vert = "┃", + -- Strong headers separate results from different files + strong_header = "━", + strong_cross = "╋", + strong_end = "┫", + -- Soft headers separate results within the same file + soft_header = "╌", + soft_cross = "╂", + soft_end = "┨", + }, + -- How to trim the leading whitespace from results. Can be 'all', 'common', or false + trim_leading_whitespace = "common", + -- Maximum width of the filename column + max_filename_width = function() + return math.floor(math.min(95, vim.o.columns / 2)) + end, + -- How far the header should extend to the right + header_length = function(type, start_col) + return vim.o.columns - start_col + end, + }) +< + +-------------------------------------------------------------------------------- +API *quicker-api* + +expand({opts}) *quicker.expand* + Expand the context around the quickfix results. + + Parameters: + {opts} `nil|quicker.ExpandOpts` + {before} `nil|integer` Number of lines of context to show + before the line (default 2) + {after} `nil|integer` Number of lines of context to show + after the line (default 2) + {add_to_existing} `nil|boolean` + {loclist_win} `nil|integer` + + Note: + If there are multiple quickfix items for the same line of a file, only the first + one will remain after calling expand(). + +collapse() *quicker.collapse* + Collapse the context around quickfix results, leaving only the `valid` + items. + + +toggle_expand({opts}) *quicker.toggle_expand* + Toggle the expanded context around the quickfix results. + + Parameters: + {opts} `nil|quicker.ExpandOpts` + {before} `nil|integer` Number of lines of context to show + before the line (default 2) + {after} `nil|integer` Number of lines of context to show + after the line (default 2) + {add_to_existing} `nil|boolean` + {loclist_win} `nil|integer` + +refresh({loclist_win}, {opts}) *quicker.refresh* + Update the quickfix list with the current buffer text for each item. + + Parameters: + {loclist_win} `nil|integer` + {opts} `nil|quicker.RefreshOpts` + {keep_diagnostics} `nil|boolean` If a line has a diagnostic type, keep + the original text and display it as virtual text + after refreshing from source. + +is_open({loclist_win}) *quicker.is_open* + + Parameters: + {loclist_win} `nil|integer` Check if loclist is open for the given window. + If nil, check quickfix. + +toggle({opts}) *quicker.toggle* + Toggle the quickfix or loclist window. + + Parameters: + {opts} `nil|quicker.OpenOpts` + {loclist} `nil|boolean` Toggle the loclist instead of the + quickfix list + {focus} `nil|boolean` Focus the quickfix window after toggling + (default false) + {height} `nil|integer` Height of the quickfix window when + opened. Defaults to number of items in the list. + {min_height} `nil|integer` Minimum height of the quickfix window. + Default 4. + {max_height} `nil|integer` Maximum height of the quickfix window. + Default 10. + {open_cmd_mods} `nil|quicker.OpenCmdMods` A table of modifiers for the + quickfix or loclist open commands. + +open({opts}) *quicker.open* + Open the quickfix or loclist window. + + Parameters: + {opts} `nil|quicker.OpenOpts` + {loclist} `nil|boolean` Toggle the loclist instead of the + quickfix list + {focus} `nil|boolean` Focus the quickfix window after toggling + (default false) + {height} `nil|integer` Height of the quickfix window when + opened. Defaults to number of items in the list. + {min_height} `nil|integer` Minimum height of the quickfix window. + Default 4. + {max_height} `nil|integer` Maximum height of the quickfix window. + Default 10. + {open_cmd_mods} `nil|quicker.OpenCmdMods` A table of modifiers for the + quickfix or loclist open commands. + +close({opts}) *quicker.close* + Close the quickfix or loclist window. + + Parameters: + {opts} `nil|quicker.CloseOpts` + {loclist} `nil|boolean` Close the loclist instead of the quickfix list + +================================================================================ +vim:tw=80:ts=2:ft=help:norl:syntax=help: diff --git a/lua/quicker/config.lua b/lua/quicker/config.lua new file mode 100644 index 0000000..716f010 --- /dev/null +++ b/lua/quicker/config.lua @@ -0,0 +1,189 @@ +local default_config = { + -- Local options to set for quickfix + opts = { + buflisted = false, + number = false, + relativenumber = false, + signcolumn = "auto", + winfixheight = true, + wrap = false, + }, + -- Set to false to disable the default options in `opts` + use_default_opts = true, + -- Keymaps to set for the quickfix buffer + keys = { + -- { ">", "lua require('quicker').expand()", desc = "Expand quickfix content" }, + }, + -- Callback function to run any custom logic or keymaps for the quickfix buffer + on_qf = function(bufnr) end, + edit = { + -- Enable editing the quickfix like a normal buffer + enabled = true, + -- Set to true to write buffers after applying edits. + -- Set to "unmodified" to only write unmodified buffers. + autosave = "unmodified", + }, + -- Keep the cursor to the right of the filename and lnum columns + constrain_cursor = true, + highlight = { + -- Use treesitter highlighting + treesitter = true, + -- Use LSP semantic token highlighting + lsp = true, + -- Load the referenced buffers to apply more accurate highlights (may be slow) + load_buffers = true, + }, + follow = { + -- When quickfix window is open, scroll to closest item to the cursor + enabled = false, + }, + -- Map of quickfix item type to icon + type_icons = { + E = "󰅚 ", + W = "󰀪 ", + I = " ", + N = " ", + H = " ", + }, + -- Border characters + borders = { + vert = "┃", + -- Strong headers separate results from different files + strong_header = "━", + strong_cross = "╋", + strong_end = "┫", + -- Soft headers separate results within the same file + soft_header = "╌", + soft_cross = "╂", + soft_end = "┨", + }, + -- How to trim the leading whitespace from results. Can be 'all', 'common', or false + trim_leading_whitespace = "common", + -- Maximum width of the filename column + max_filename_width = function() + return math.floor(math.min(95, vim.o.columns / 2)) + end, + -- How far the header should extend to the right + header_length = function(type, start_col) + return vim.o.columns - start_col + end, +} + +---@alias quicker.TrimEnum "all"|"common"|false + +---@class quicker.Config +---@field on_qf fun(bufnr: number) +---@field opts table +---@field keys quicker.Keymap[] +---@field use_default_opts boolean +---@field constrain_cursor boolean +---@field highlight quicker.HighlightConfig +---@field follow quicker.FollowConfig +---@field edit quicker.EditConfig +---@field type_icons table +---@field borders quicker.Borders +---@field trim_leading_whitespace quicker.TrimEnum +---@field max_filename_width fun(): integer +---@field header_length fun(type: "hard"|"soft", start_col: integer): integer +local M = {} + +---@class (exact) quicker.SetupOptions +---@field on_qf? fun(bufnr: number) Callback function to run any custom logic or keymaps for the quickfix buffer +---@field opts? table Local options to set for quickfix +---@field keys? quicker.Keymap[] Keymaps to set for the quickfix buffer +---@field use_default_opts? boolean Set to false to disable the default options in `opts` +---@field constrain_cursor? boolean Keep the cursor to the right of the filename and lnum columns +---@field highlight? quicker.SetupHighlightConfig Configure syntax highlighting +---@field follow? quicker.SetupFollowConfig Configure cursor following +---@field edit? quicker.SetupEditConfig +---@field type_icons? table Map of quickfix item type to icon +---@field borders? quicker.SetupBorders Characters used for drawing the borders +---@field trim_leading_whitespace? quicker.TrimEnum How to trim the leading whitespace from results +---@field max_filename_width? fun(): integer Maximum width of the filename column +---@field header_length? fun(type: "hard"|"soft", start_col: integer): integer How far the header should extend to the right + +local has_setup = false +---@param opts? quicker.SetupOptions +M.setup = function(opts) + opts = opts or {} + local new_conf = vim.tbl_deep_extend("keep", opts, default_config) + + for k, v in pairs(new_conf) do + M[k] = v + end + + -- Shim for when this was only a boolean. 'true' meant 'common' + if M.trim_leading_whitespace == true then + M.trim_leading_whitespace = "common" + end + + -- Remove the default opts values if use_default_opts is false + if not new_conf.use_default_opts then + M.opts = opts.opts or {} + end + has_setup = true +end + +---@class (exact) quicker.Keymap +---@field [1] string Key sequence +---@field [2] any Command to run +---@field desc? string +---@field mode? string +---@field expr? boolean +---@field nowait? boolean +---@field remap? boolean +---@field replace_keycodes? boolean +---@field silent? boolean + +---@class (exact) quicker.Borders +---@field vert string +---@field strong_header string +---@field strong_cross string +---@field strong_end string +---@field soft_header string +---@field soft_cross string +---@field soft_end string + +---@class (exact) quicker.SetupBorders +---@field vert? string +---@field strong_header? string Strong headers separate results from different files +---@field strong_cross? string +---@field strong_end? string +---@field soft_header? string Soft headers separate results within the same file +---@field soft_cross? string +---@field soft_end? string + +---@class (exact) quicker.HighlightConfig +---@field treesitter boolean +---@field lsp boolean +---@field load_buffers boolean + +---@class (exact) quicker.SetupHighlightConfig +---@field treesitter? boolean Enable treesitter syntax highlighting +---@field lsp? boolean Use LSP semantic token highlighting +---@field load_buffers? boolean Load the referenced buffers to apply more accurate highlights (may be slow) + +---@class (exact) quicker.FollowConfig +---@field enabled boolean + +---@class (exact) quicker.SetupFollowConfig +---@field enabled? boolean + +---@class (exact) quicker.EditConfig +---@field enabled boolean +---@field autosave boolean|"unmodified" + +---@class (exact) quicker.SetupEditConfig +---@field enabled? boolean +---@field autosave? boolean|"unmodified" + +return setmetatable(M, { + -- If the user hasn't called setup() yet, make sure we correctly set up the config object so there + -- aren't random crashes. + __index = function(self, key) + if not has_setup then + M.setup() + end + return rawget(self, key) + end, +}) diff --git a/lua/quicker/context.lua b/lua/quicker/context.lua new file mode 100644 index 0000000..f5bbb87 --- /dev/null +++ b/lua/quicker/context.lua @@ -0,0 +1,315 @@ +local util = require("quicker.util") + +local M = {} + +---@class (exact) quicker.QFContext +---@field num_before integer +---@field num_after integer + +---@class (exact) quicker.ExpandOpts +---@field before? integer Number of lines of context to show before the line (default 2) +---@field after? integer Number of lines of context to show after the line (default 2) +---@field add_to_existing? boolean +---@field loclist_win? integer + +---@param item QuickFixItem +---@param new_text string +local function update_item_text_keep_diagnostics(item, new_text) + -- If this is an "error" item, replace the text with the source line and store that text + -- in the user data so we can add it as virtual text later + if item.type ~= "" and not vim.endswith(new_text, item.text) then + local user_data = util.get_user_data(item) + if not user_data.error_text then + user_data.error_text = item.text + item.user_data = user_data + end + end + item.text = new_text +end + +---@param opts? quicker.ExpandOpts +function M.expand(opts) + opts = opts or {} + if not opts.loclist_win and util.get_win_type(0) == "l" then + opts.loclist_win = vim.api.nvim_get_current_win() + end + local qf_list + if opts.loclist_win then + qf_list = vim.fn.getloclist(opts.loclist_win, { all = 0 }) + else + qf_list = vim.fn.getqflist({ all = 0 }) + end + local winid = qf_list.winid + if not winid then + vim.notify("Cannot find quickfix window", vim.log.levels.ERROR) + return + end + local ctx = qf_list.context or {} + if type(ctx) ~= "table" then + -- If the quickfix had a non-table context, we're going to have to overwrite it + ctx = {} + end + ---@type quicker.QFContext + local quicker_ctx = ctx.quicker + if not quicker_ctx then + quicker_ctx = { num_before = 0, num_after = 0 } + ctx.quicker = quicker_ctx + end + local curpos = vim.api.nvim_win_get_cursor(winid)[1] + local cur_item = qf_list.items[curpos] + local newpos + + -- calculate the number of lines to show before and after the current line + local num_before = opts.before or 2 + if opts.add_to_existing then + num_before = num_before + quicker_ctx.num_before + end + num_before = math.max(0, num_before) + quicker_ctx.num_before = num_before + local num_after = opts.after or 2 + if opts.add_to_existing then + num_after = num_after + quicker_ctx.num_after + end + num_after = math.max(0, num_after) + quicker_ctx.num_after = num_after + + local items = {} + ---@type nil|QuickFixItem + local prev_item + ---@param i integer + ---@return nil|QuickFixItem + local function get_next_item(i) + local item = qf_list.items[i] + for j = i + 1, #qf_list.items do + local next_item = qf_list.items[j] + -- Next valid item that is on a different line (since we dedupe same-line items) + if + next_item.valid == 1 and (item.bufnr ~= next_item.bufnr or item.lnum ~= next_item.lnum) + then + return next_item + end + end + end + + for i, item in ipairs(qf_list.items) do + (function() + ---@cast item QuickFixItem + if item.valid == 0 or item.bufnr == 0 then + return + end + + if not vim.api.nvim_buf_is_loaded(item.bufnr) then + vim.fn.bufload(item.bufnr) + end + + local overlaps_previous = false + local header_type = "hard" + local low = math.max(0, item.lnum - 1 - num_before) + if prev_item then + if prev_item.bufnr == item.bufnr then + -- If this is the second match on the same line, skip this item + if prev_item.lnum == item.lnum then + return + end + header_type = "soft" + if prev_item.lnum + num_after >= low then + low = math.min(item.lnum - 1, prev_item.lnum + num_after) + overlaps_previous = true + end + end + end + + local high = item.lnum + num_after + local next_item = get_next_item(i) + if next_item then + if next_item.bufnr == item.bufnr and next_item.lnum <= high then + high = next_item.lnum - 1 + end + end + + local item_start_idx = #items + local lines = vim.api.nvim_buf_get_lines(item.bufnr, low, high, false) + for j, line in ipairs(lines) do + if j + low == item.lnum then + update_item_text_keep_diagnostics(item, line) + table.insert(items, item) + else + table.insert(items, { + bufnr = item.bufnr, + lnum = low + j, + text = line, + valid = 0, + user_data = { lnum = low + j }, + }) + end + if cur_item.bufnr == item.bufnr and cur_item.lnum == low + j then + newpos = #items + end + end + + -- Add the header to the first item in this sequence, if one is needed + if prev_item and not overlaps_previous then + local first_item = items[item_start_idx + 1] + if first_item then + first_item.user_data = first_item.user_data or {} + first_item.user_data.header = header_type + end + end + + prev_item = item + end)() + + if i == curpos and not newpos then + newpos = #items + end + end + + if opts.loclist_win then + vim.fn.setloclist( + opts.loclist_win, + {}, + "r", + { items = items, title = qf_list.title, context = ctx } + ) + else + vim.fn.setqflist({}, "r", { items = items, title = qf_list.title, context = ctx }) + end + + pcall(vim.api.nvim_win_set_cursor, qf_list.winid, { newpos, 0 }) +end + +---@class (exact) quicker.CollapseArgs +---@field loclist_win? integer +--- +function M.collapse(opts) + opts = opts or {} + if not opts.loclist_win and util.get_win_type(0) == "l" then + opts.loclist_win = vim.api.nvim_get_current_win() + end + local curpos = vim.api.nvim_win_get_cursor(0)[1] + local qf_list + if opts.loclist_win then + qf_list = vim.fn.getloclist(opts.loclist_win, { all = 0 }) + else + qf_list = vim.fn.getqflist({ all = 0 }) + end + local items = {} + local last_item + for i, item in ipairs(qf_list.items) do + if item.valid == 1 then + if item.user_data then + -- Clear the header, if present + item.user_data.header = nil + end + table.insert(items, item) + if i <= curpos then + last_item = #items + end + end + end + + vim.tbl_filter(function(item) + return item.valid == 1 + end, qf_list.items) + + local ctx = qf_list.context or {} + if type(ctx) == "table" then + local quicker_ctx = ctx.quicker + if quicker_ctx then + quicker_ctx = { num_before = 0, num_after = 0 } + ctx.quicker = quicker_ctx + end + end + + if opts.loclist_win then + vim.fn.setloclist( + opts.loclist_win, + {}, + "r", + { items = items, title = qf_list.title, context = qf_list.context } + ) + else + vim.fn.setqflist({}, "r", { items = items, title = qf_list.title, context = qf_list.context }) + end + if qf_list.winid then + if last_item then + vim.api.nvim_win_set_cursor(qf_list.winid, { last_item, 0 }) + end + end +end + +---@param opts? quicker.ExpandOpts +function M.toggle(opts) + opts = opts or {} + local ctx + if opts.loclist_win then + ctx = vim.fn.getloclist(opts.loclist_win, { context = 0 }).context + else + ctx = vim.fn.getqflist({ context = 0 }).context + end + + if + type(ctx) == "table" + and ctx.quicker + and (ctx.quicker.num_before > 0 or ctx.quicker.num_after > 0) + then + M.collapse() + else + M.expand(opts) + end +end + +---@class (exact) quicker.RefreshOpts +---@field keep_diagnostics? boolean If a line has a diagnostic type, keep the original text and display it as virtual text after refreshing from source. + +---@param loclist_win? integer +---@param opts? quicker.RefreshOpts +function M.refresh(loclist_win, opts) + opts = vim.tbl_extend("keep", opts or {}, { keep_diagnostics = true }) + if not loclist_win then + local ok, qf = pcall(vim.fn.getloclist, 0, { filewinid = 0 }) + if ok and qf.filewinid and qf.filewinid ~= 0 then + loclist_win = qf.filewinid + end + end + + local qf_list + if loclist_win then + qf_list = vim.fn.getloclist(loclist_win, { all = 0 }) + else + qf_list = vim.fn.getqflist({ all = 0 }) + end + + local items = {} + for _, item in ipairs(qf_list.items) do + if item.bufnr ~= 0 and item.lnum ~= 0 then + if not vim.api.nvim_buf_is_loaded(item.bufnr) then + vim.fn.bufload(item.bufnr) + end + local line = vim.api.nvim_buf_get_lines(item.bufnr, item.lnum - 1, item.lnum, false)[1] + if line then + if opts.keep_diagnostics then + update_item_text_keep_diagnostics(item, line) + else + item.text = line + end + table.insert(items, item) + end + else + table.insert(items, item) + end + end + + if loclist_win then + vim.fn.setloclist( + loclist_win, + {}, + "r", + { items = items, title = qf_list.title, context = qf_list.context } + ) + else + vim.fn.setqflist({}, "r", { items = items, title = qf_list.title, context = qf_list.context }) + end +end + +return M diff --git a/lua/quicker/cursor.lua b/lua/quicker/cursor.lua new file mode 100644 index 0000000..58ee587 --- /dev/null +++ b/lua/quicker/cursor.lua @@ -0,0 +1,44 @@ +local M = {} + +local function constrain_cursor() + local display = require("quicker.display") + local cur = vim.api.nvim_win_get_cursor(0) + local line = vim.api.nvim_buf_get_lines(0, cur[1] - 1, cur[1], true)[1] + local idx = line:find(display.EM_QUAD, 1, true) + if not idx then + return + end + local min_col = idx + display.EM_QUAD_LEN - 1 + if cur[2] < min_col then + vim.api.nvim_win_set_cursor(0, { cur[1], min_col }) + end +end + +---@param bufnr number +function M.constrain_cursor(bufnr) + -- HACK: we have to defer this call because sometimes the autocmds don't take effect. + vim.schedule(function() + if not vim.api.nvim_buf_is_valid(bufnr) then + return + end + local aug = vim.api.nvim_create_augroup("quicker", { clear = false }) + vim.api.nvim_create_autocmd("InsertEnter", { + desc = "Constrain quickfix cursor position", + group = aug, + nested = true, + buffer = bufnr, + -- For some reason the cursor bounces back to its original position, + -- so we have to defer the call + callback = vim.schedule_wrap(constrain_cursor), + }) + vim.api.nvim_create_autocmd({ "CursorMoved", "ModeChanged" }, { + desc = "Constrain quickfix cursor position", + nested = true, + group = aug, + buffer = bufnr, + callback = constrain_cursor, + }) + end) +end + +return M diff --git a/lua/quicker/display.lua b/lua/quicker/display.lua new file mode 100644 index 0000000..5c551d3 --- /dev/null +++ b/lua/quicker/display.lua @@ -0,0 +1,601 @@ +local config = require("quicker.config") +local fs = require("quicker.fs") +local highlight = require("quicker.highlight") +local util = require("quicker.util") + +local M = {} + +local EM_QUAD = " " +local EM_QUAD_LEN = EM_QUAD:len() +M.EM_QUAD = EM_QUAD +M.EM_QUAD_LEN = EM_QUAD_LEN + +---@class (exact) QuickFixUserData +---@field header? "hard"|"soft" When present, this line is a header +---@field lnum? integer Encode the lnum separately for valid=0 items +---@field error_text? string Error text to be added as virtual text on the line + +---@class (exact) QuickFixItem +---@field text string +---@field type string +---@field lnum integer line number in the buffer (first line is 1) +---@field end_lnum integer end of line number if the item is multiline +---@field col integer column number (first column is 1) +---@field end_col integer end of column number if the item has range +---@field vcol 0|1 if true "col" is visual column. If false "col" is byte index +---@field nr integer error number +---@field pattern string search pattern used to locate the error +---@field bufnr integer number of buffer that has the file name +---@field module string +---@field valid 0|1 +---@field user_data? any + +---@param type string +---@return string +local function get_icon(type) + return config.type_icons[type:upper()] or "U" +end + +local sign_highlight_map = { + E = "DiagnosticSignError", + W = "DiagnosticSignWarn", + I = "DiagnosticSignInfo", + H = "DiagnosticSignHint", + N = "DiagnosticSignHint", +} +local virt_text_highlight_map = { + E = "DiagnosticVirtualTextError", + W = "DiagnosticVirtualTextWarn", + I = "DiagnosticVirtualTextInfo", + H = "DiagnosticVirtualTextHint", + N = "DiagnosticVirtualTextHint", +} + +---@param item QuickFixItem +M.get_filename_from_item = function(item) + if item.module and item.module ~= "" then + return item.module + elseif item.bufnr > 0 then + local bufname = vim.api.nvim_buf_get_name(item.bufnr) + local path = fs.shorten_path(bufname) + local max_len = config.max_filename_width() + if max_len == 0 then + return "" + elseif path:len() > max_len then + path = "…" .. path:sub(path:len() - max_len - 1) + end + return path + else + return "" + end +end + +local _col_width_cache = {} +---@param id integer +---@param items QuickFixItem[] +---@return integer +local function get_cached_qf_col_width(id, items) + local cached = _col_width_cache[id] + if not cached or cached[2] ~= #items then + local max_len = 0 + for _, item in ipairs(items) do + max_len = math.max(max_len, vim.api.nvim_strwidth(M.get_filename_from_item(item))) + end + + cached = { max_len, #items } + _col_width_cache[id] = cached + end + return cached[1] +end + +---@param items QuickFixItem[] +---@return table +local function calc_whitespace_prefix(items) + local prefixes = {} + if config.trim_leading_whitespace ~= "common" then + return prefixes + end + + for _, item in ipairs(items) do + if item.bufnr ~= 0 and not item.text:match("^%s*$") then + local prefix = prefixes[item.bufnr] + if not prefix or not vim.startswith(item.text, prefix) then + local new_prefix = item.text:match("^%s*") + + -- The new line should have strictly less whitespace as the previous line. If not, then + -- there is some whitespace disagreement (e.g. tabs vs spaces) and we should not try to trim + -- anything. + if prefix and not vim.startswith(prefix, new_prefix) then + new_prefix = "" + end + prefixes[item.bufnr] = new_prefix + + if new_prefix == "" then + break + end + end + end + end + return prefixes +end + +-- Highlighting can be slow because it requires loading buffers and parsing them with treesitter, so +-- we pipeline it and break it up with defers to keep the editor responsive. +local add_qf_highlights +-- We have two queues, one to apply "fast" highlights, and one that will load the buffer (slow) +-- and then apply more correct highlights. The second queue is always processed after the first. +local _pending_fast_highlights = {} +local _pending_bufload_highlights = {} +local _running = false +local function do_next_highlight() + if _running then + return + end + _running = true + + local next_info = table.remove(_pending_fast_highlights, 1) + if not next_info then + next_info = table.remove(_pending_bufload_highlights, 1) + end + + if next_info then + local ok, err = xpcall(add_qf_highlights, debug.traceback, next_info) + if not ok then + vim.api.nvim_err_writeln(err) + end + else + _running = false + return + end + + vim.defer_fn(function() + _running = false + do_next_highlight() + end, 20) +end + +---@param queue QuickFixTextFuncInfo[] +---@param info QuickFixTextFuncInfo +local function add_info_to_queue(queue, info) + for _, i in ipairs(queue) do + -- If we're already processing a highlight for this quickfix, just expand the range + if i.id == info.id and i.winid == info.winid and i.quickfix == info.quickfix then + i.start_idx = math.min(i.start_idx, info.start_idx) + i.end_idx = math.max(i.end_idx, info.end_idx) + return + end + end + table.insert(queue, info) +end + +---@param info QuickFixTextFuncInfo +local function schedule_highlights(info) + -- If this info already has force_bufload, then we don't want to add it to the first queue. + if not info.force_bufload then + add_info_to_queue(_pending_fast_highlights, info) + end + + if config.highlight.load_buffers then + local info2 = vim.deepcopy(info) + info2.force_bufload = true + add_info_to_queue(_pending_bufload_highlights, info2) + end + + vim.schedule(do_next_highlight) +end + +---@param qfbufnr integer +---@param item QuickFixItem +---@param line string +---@param lnum integer +local function add_item_highlights_from_buf(qfbufnr, item, line, lnum) + local prefixes = vim.b[qfbufnr].qf_prefixes or {} + local ns = vim.api.nvim_create_namespace("quicker_highlights") + -- TODO re-apply highlights when a buffer is loaded or a LSP receives semantic tokens + local src_line = vim.api.nvim_buf_get_lines(item.bufnr, item.lnum - 1, item.lnum, false)[1] + if not src_line then + return + end + + -- If the lines differ only in leading whitespace, we should add highlights anyway and adjust + -- the offset. + local item_space = item.text:match("^%s*"):len() + local src_space = src_line:match("^%s*"):len() + + -- Only add highlights if the text in the quickfix matches the source line + if item.text:sub(item_space + 1) == src_line:sub(src_space + 1) then + local offset = line:find(EM_QUAD, 1, true) + EM_QUAD_LEN - 1 + local prefix = prefixes[item.bufnr] + if type(prefix) == "string" then + -- Since prefixes get deserialized from vim.b, if there are holes in the map they get + -- filled with `vim.NIL`, so we have to check that the retrieved value is a string. + offset = offset - prefix:len() + end + offset = offset - src_space + item_space + if config.trim_leading_whitespace == "all" then + offset = offset - item_space + end + + -- Add treesitter highlights + if config.highlight.treesitter then + for _, hl in ipairs(highlight.buf_get_ts_highlights(item.bufnr, item.lnum)) do + local start_col, end_col, hl_group = hl[1], hl[2], hl[3] + if end_col == -1 then + end_col = src_line:len() + end + -- If the highlight starts at the beginning of the source line, then it might be off the + -- buffer in the quickfix because we've removed leading whitespace. If so, clamp the value + -- to 0. Except, for some reason 0 gives incorrect results, but -1 works properly even + -- though -1 should indicate the *end* of the line. Not sure why this work, but it does. + local hl_start = math.max(-1, start_col + offset) + vim.api.nvim_buf_set_extmark(qfbufnr, ns, lnum - 1, hl_start, { + hl_group = hl_group, + end_col = end_col + offset, + priority = 100, + strict = false, + }) + end + end + + -- Add LSP semantic token highlights + if config.highlight.lsp then + for _, hl in ipairs(highlight.buf_get_lsp_highlights(item.bufnr, item.lnum)) do + local start_col, end_col, hl_group, priority = hl[1], hl[2], hl[3], hl[4] + vim.api.nvim_buf_set_extmark(qfbufnr, ns, lnum - 1, start_col + offset, { + hl_group = hl_group, + end_col = end_col + offset, + priority = vim.highlight.priorities.semantic_tokens + priority, + strict = false, + }) + end + end + end +end + +---@param qfbufnr integer +---@param info QuickFixTextFuncInfo +local function highlight_buffer_when_entered(qfbufnr, info) + if vim.b[qfbufnr].pending_highlight then + return + end + vim.api.nvim_create_autocmd("BufEnter", { + desc = "Highlight quickfix buffer when entered", + buffer = qfbufnr, + nested = true, + once = true, + callback = function() + vim.b[qfbufnr].pending_highlight = nil + info.start_idx = 1 + info.end_idx = vim.api.nvim_buf_line_count(qfbufnr) + schedule_highlights(info) + end, + }) + vim.b[qfbufnr].pending_highlight = true +end + +---@param info QuickFixTextFuncInfo +---@return {qfbufnr: integer, id: integer, context?: any} +---@overload fun(info: QuickFixTextFuncInfo, all: true): {qfbufnr: integer, id: integer, items: QuickFixItem[], context?: any} +local function load_qf(info, all) + local query + if all then + query = { all = 0 } + else + query = { id = info.id, items = 0, qfbufnr = 0, context = 0 } + end + if info.quickfix == 1 then + return vim.fn.getqflist(query) + else + return vim.fn.getloclist(info.winid, query) + end +end + +---@param info QuickFixTextFuncInfo +add_qf_highlights = function(info) + local qf_list = load_qf(info, true) + local qfbufnr = qf_list.qfbufnr + if not qfbufnr or qfbufnr == 0 then + return + elseif info.end_idx < info.start_idx then + return + end + + local lines = vim.api.nvim_buf_get_lines(qfbufnr, 0, -1, false) + if #lines == 1 and lines[1] == "" then + -- If the quickfix buffer is not visible, it is possible that quickfixtextfunc has run but the + -- buffer has not been populated yet. If that is the case, we should exit early and ensure that + -- the highlighting task runs again when the buffer is opened in a window. + -- see https://github.com/stevearc/quicker.nvim/pull/8 + highlight_buffer_when_entered(qfbufnr, info) + return + end + local ns = vim.api.nvim_create_namespace("quicker_highlights") + + -- Only clear the error namespace during the first pass of "fast" highlighting + if not info.force_bufload then + local err_ns = vim.api.nvim_create_namespace("quicker_err") + vim.api.nvim_buf_clear_namespace(qfbufnr, err_ns, 0, -1) + end + + local start = vim.uv.hrtime() / 1e6 + for i = info.start_idx, info.end_idx do + vim.api.nvim_buf_clear_namespace(qfbufnr, ns, i - 1, i) + ---@type nil|QuickFixItem + local item = qf_list.items[i] + -- If the quickfix list has changed length since the async highlight job has started, + -- we should abort and let the next async highlight task pick it up. + if not item then + return + end + + local line = lines[i] + if not line then + break + end + if item.bufnr ~= 0 then + local loaded = vim.api.nvim_buf_is_loaded(item.bufnr) + if not loaded and info.force_bufload then + vim.fn.bufload(item.bufnr) + loaded = true + end + + if loaded then + add_item_highlights_from_buf(qfbufnr, item, line, i) + elseif config.highlight.treesitter then + local filename = vim.split(line, EM_QUAD, { plain = true })[1] + local offset = filename:len() + EM_QUAD_LEN + local text = line:sub(offset + 1) + for _, hl in ipairs(highlight.get_heuristic_ts_highlights(item, text)) do + local start_col, end_col, hl_group = hl[1], hl[2], hl[3] + start_col = start_col + offset + end_col = end_col + offset + vim.api.nvim_buf_set_extmark(qfbufnr, ns, i - 1, start_col, { + hl_group = hl_group, + end_col = end_col, + priority = 100, + strict = false, + }) + end + end + end + + local user_data = util.get_user_data(item) + -- Set sign if item has a type + if item.type and item.type ~= "" then + local mark = { + sign_text = get_icon(item.type), + sign_hl_group = sign_highlight_map[item.type:upper()], + invalidate = true, + } + if user_data.error_text then + mark.virt_text = { + { user_data.error_text, virt_text_highlight_map[item.type:upper()] or "Normal" }, + } + end + vim.api.nvim_buf_set_extmark(qfbufnr, ns, i - 1, 0, mark) + end + + -- If we've been processing for too long, defer to preserve editor responsiveness + local delta = vim.uv.hrtime() / 1e6 - start + if delta > 50 then + info.start_idx = i + 1 + schedule_highlights(info) + return + end + end + + vim.api.nvim_buf_clear_namespace(qf_list.qfbufnr, ns, info.end_idx, -1) +end + +---@param str string +---@param len integer +---@return string +local function rpad(str, len) + return str .. string.rep(" ", len - vim.api.nvim_strwidth(str)) +end + +---@param items QuickFixItem[] +---@return integer +local function get_lnum_width(items) + local max_len = 2 + local max = 99 + for _, item in ipairs(items) do + if item.lnum > max then + max_len = tostring(item.lnum):len() + max = item.lnum + end + end + return max_len +end + +---@param text string +---@param prefix? string +local function remove_prefix(text, prefix) + local ret + if prefix and prefix ~= "" then + ret = text:sub(prefix:len() + 1) + else + ret = text + end + + return ret +end + +---@class QuickFixTextFuncInfo +---@field id integer +---@field start_idx integer +---@field end_idx integer +---@field winid integer +---@field quickfix 1|0 +---@field force_bufload? boolean field injected by us to control if we're forcing a bufload for the syntax highlighting + +-- TODO when appending to a qflist, the alignment can be thrown off +-- TODO when appending to a qflist, the prefix could mismatch earlier lines +---@param info QuickFixTextFuncInfo +---@return string[] +function M.quickfixtextfunc(info) + local b = config.borders + local qf_list = load_qf(info, true) + local locations = {} + local invalid_filenames = {} + local headers = {} + local ret = {} + local items = qf_list.items + local lnum_width = get_lnum_width(items) + local col_width = get_cached_qf_col_width(info.id, items) + local lnum_fmt = string.format("%%%ds", lnum_width) + local prefixes = calc_whitespace_prefix(items) + local no_filenames = col_width == 0 + + local function get_virt_text(lnum) + -- If none of the quickfix items have filenames, we don't need the lnum column and we only need + -- to show a single delimiter. Technically we don't need any delimiter, but this maintains some + -- of the original qf behavior while being a bit more visually appealing. + if no_filenames then + return { { b.vert, "Delimiter" } } + else + return { + { b.vert, "Delimiter" }, + { lnum_fmt:format(lnum), "QuickFixLineNr" }, + { b.vert, "Delimiter" }, + } + end + end + + for i = info.start_idx, info.end_idx do + local item = items[i] + local user_data = util.get_user_data(item) + + -- First check if there's a header that we need to save to render as virtual text later + if user_data.header == "hard" then + -- Header when expanded QF list + local pieces = { + string.rep(b.strong_header, col_width + 1), + b.strong_cross, + string.rep(b.strong_header, lnum_width), + } + local header_len = config.header_length("hard", col_width + lnum_width + 2) + if header_len > 0 then + table.insert(pieces, b.strong_cross) + table.insert(pieces, string.rep(b.strong_header, header_len)) + else + table.insert(pieces, b.strong_end) + end + table.insert(headers, { i, { { table.concat(pieces, ""), "QuickFixHeaderHard" } } }) + elseif user_data.header == "soft" then + -- Soft header when expanded QF list + local pieces = { + string.rep(b.soft_header, col_width + 1), + b.soft_cross, + string.rep(b.soft_header, lnum_width), + } + local header_len = config.header_length("soft", col_width + lnum_width + 2) + if header_len > 0 then + table.insert(pieces, b.soft_cross) + table.insert(pieces, string.rep(b.soft_header, header_len)) + else + table.insert(pieces, b.soft_end) + end + table.insert(headers, { i, { { table.concat(pieces, ""), "QuickFixHeaderSoft" } } }) + end + + -- Construct the lines and save the filename + lnum to render as virtual text later + local trimmed_text + if config.trim_leading_whitespace == "all" then + trimmed_text = item.text:gsub("^%s*", "") + elseif config.trim_leading_whitespace == "common" then + trimmed_text = remove_prefix(item.text, prefixes[item.bufnr]) + else + trimmed_text = item.text + end + if item.valid == 1 then + -- Matching line + local lnum = item.lnum == 0 and " " or item.lnum + local filename = rpad(M.get_filename_from_item(item), col_width) + table.insert(locations, get_virt_text(lnum)) + table.insert(ret, filename .. EM_QUAD .. trimmed_text) + elseif user_data.lnum then + -- Non-matching line from quicker.nvim context lines + local filename = string.rep(" ", col_width) + table.insert(locations, get_virt_text(user_data.lnum)) + table.insert(ret, filename .. EM_QUAD .. trimmed_text) + else + -- Other non-matching line + local lnum = item.lnum == 0 and " " or item.lnum + local filename = rpad(M.get_filename_from_item(item), col_width) + table.insert(locations, get_virt_text(lnum)) + invalid_filenames[#locations] = true + table.insert(ret, filename .. EM_QUAD .. trimmed_text) + end + end + + -- Render the filename+lnum and the headers as virtual text + local start_idx = info.start_idx + local set_virt_text + set_virt_text = function() + qf_list = load_qf(info) + if qf_list.qfbufnr > 0 then + -- Sometimes the buffer is not fully populated yet. If so, we should try again later. + local num_lines = vim.api.nvim_buf_line_count(qf_list.qfbufnr) + if num_lines < info.end_idx then + vim.schedule(set_virt_text) + return + end + + local ns = vim.api.nvim_create_namespace("quicker_locations") + vim.api.nvim_buf_clear_namespace(qf_list.qfbufnr, ns, start_idx - 1, -1) + local header_ns = vim.api.nvim_create_namespace("quicker_headers") + vim.api.nvim_buf_clear_namespace(qf_list.qfbufnr, header_ns, start_idx - 1, -1) + local filename_ns = vim.api.nvim_create_namespace("quicker_filenames") + vim.api.nvim_buf_clear_namespace(qf_list.qfbufnr, filename_ns, start_idx - 1, -1) + + local idmap = {} + local lines = vim.api.nvim_buf_get_lines(qf_list.qfbufnr, start_idx - 1, -1, false) + for i, loc in ipairs(locations) do + local end_col = lines[i]:find(EM_QUAD, 1, true) or col_width + local lnum = start_idx + i - 1 + local id = + vim.api.nvim_buf_set_extmark(qf_list.qfbufnr, ns, lnum - 1, end_col + EM_QUAD_LEN - 1, { + right_gravity = false, + virt_text = loc, + virt_text_pos = "inline", + invalidate = true, + }) + idmap[id] = lnum + + -- Highlight the filename + vim.api.nvim_buf_set_extmark(qf_list.qfbufnr, filename_ns, lnum - 1, 0, { + hl_group = invalid_filenames[i] and "QuickFixFilenameInvalid" or "QuickFixFilename", + right_gravity = false, + end_col = end_col, + priority = 100, + invalidate = true, + }) + end + vim.b[qf_list.qfbufnr].qf_ext_id_to_item_idx = idmap + + for _, pair in ipairs(headers) do + local i, header = pair[1], pair[2] + local lnum = start_idx + i - 1 + vim.api.nvim_buf_set_extmark(qf_list.qfbufnr, header_ns, lnum - 1, 0, { + virt_lines = { header }, + virt_lines_above = true, + }) + end + end + end + vim.schedule(set_virt_text) + + -- If we just rendered the last item, add highlights + if info.end_idx == #items then + schedule_highlights(info) + + if qf_list.qfbufnr > 0 then + vim.b[qf_list.qfbufnr].qf_prefixes = prefixes + end + end + + return ret +end + +return M diff --git a/lua/quicker/editor.lua b/lua/quicker/editor.lua new file mode 100644 index 0000000..3f5db65 --- /dev/null +++ b/lua/quicker/editor.lua @@ -0,0 +1,405 @@ +local config = require("quicker.config") +local display = require("quicker.display") +local util = require("quicker.util") +local M = {} + +---@class (exact) quicker.ParsedLine +---@field filename? string +---@field lnum? integer +---@field text? string + +---@param n integer +---@param base string +---@param pluralized? string +---@return string +local function plural(n, base, pluralized) + if n == 1 then + return base + elseif pluralized then + return pluralized + else + return base .. "s" + end +end + +---Replace the text in a quickfix line, preserving the lineno virt text +---@param bufnr integer +---@param lnum integer +---@param new_text string +local function replace_qf_line(bufnr, lnum, new_text) + local old_line = vim.api.nvim_buf_get_lines(bufnr, lnum - 1, lnum, true)[1] + + local old_idx = old_line:find(display.EM_QUAD, 1, true) + local new_idx = new_text:find(display.EM_QUAD, 1, true) + + -- If we're missing the em quad delimiter in either the old or new text, the best we can do is + -- replace the whole line + if not old_idx or not new_idx then + vim.api.nvim_buf_set_text(bufnr, lnum - 1, 0, lnum - 1, -1, { new_text }) + return + end + + -- Replace first the text after the em quad, then the filename before. + -- This keeps the line number virtual text in the same location. + vim.api.nvim_buf_set_text( + bufnr, + lnum - 1, + old_idx + display.EM_QUAD_LEN - 1, + lnum - 1, + -1, + { new_text:sub(new_idx + display.EM_QUAD_LEN) } + ) + vim.api.nvim_buf_set_text(bufnr, lnum - 1, 0, lnum - 1, old_idx, { new_text:sub(1, new_idx) }) +end + +---@param bufnr integer +---@param lnum integer +---@param text string +---@param text_hl? string +local function add_qf_error(bufnr, lnum, text, text_hl) + local line = vim.api.nvim_buf_get_lines(bufnr, lnum - 1, lnum, true)[1] + local col = line:find(config.borders.vert, 1, true) + if col then + col = line:find(config.borders.vert, col + config.borders.vert:len(), true) + + config.borders.vert:len() + - 1 + else + col = 0 + end + local offset = vim.api.nvim_strwidth(line:sub(1, col)) + local ns = vim.api.nvim_create_namespace("quicker_err") + vim.api.nvim_buf_set_extmark(bufnr, ns, lnum - 1, col, { + virt_text = { { config.type_icons.E, "DiagnosticSignError" } }, + virt_text_pos = "inline", + virt_lines = { + { + { string.rep(" ", offset), "Normal" }, + { "↳ ", "DiagnosticError" }, + { text, text_hl or "Normal" }, + }, + }, + }) +end + +---@param item QuickFixItem +---@param needle string +---@param src_line nil|string +---@return nil|table text_change +---@return nil|string error +local function get_text_edit(item, needle, src_line) + if not src_line then + return nil + elseif item.text == needle then + return nil + elseif src_line ~= item.text then + if item.text:gsub("^%s*", "") == src_line:gsub("^%s*", "") then + -- If they only disagree in their leading whitespace, just take the changes after the + -- whitespace and assume that the whitespace hasn't changed. + -- This can happen if the setqflist caller doesn't use the same whitespace as the source file, + -- for example overseer.nvim Grep will convert tabs to spaces because the embedded terminal + -- will convert tabs to spaces. + needle = src_line:match("^%s*") .. needle:gsub("^%s*", "") + else + return nil, "buffer text does not match source text" + end + end + + return { + newText = needle, + range = { + start = { + line = item.lnum - 1, + character = 0, + }, + ["end"] = { + line = item.lnum - 1, + character = #src_line, + }, + }, + } +end + +---Deserialize qf_prefixes from the buffer, converting vim.NIL to nil +---@param bufnr integer +---@return table +local function load_qf_prefixes(bufnr) + local prefixes = vim.b[bufnr].qf_prefixes or {} + for k, v in pairs(prefixes) do + if v == vim.NIL then + prefixes[k] = nil + end + end + return prefixes +end + +---@param bufnr integer +---@param loclist_win? integer +local function save_changes(bufnr, loclist_win) + if not vim.bo[bufnr].modified then + return + end + local ns = vim.api.nvim_create_namespace("quicker_err") + vim.api.nvim_buf_clear_namespace(bufnr, ns, 0, -1) + local qf_list + if loclist_win then + qf_list = vim.fn.getloclist(loclist_win, { all = 0 }) + else + qf_list = vim.fn.getqflist({ all = 0 }) + end + + local changes = {} + local function add_change(buf, text_edit) + if not changes[buf] then + changes[buf] = {} + end + local last_edit = changes[buf][#changes[buf]] + if last_edit and vim.deep_equal(last_edit.range, text_edit.range) then + if last_edit.newText == text_edit.newText then + return + else + return "conflicting changes on the same line" + end + end + table.insert(changes[buf], text_edit) + end + + -- Parse the buffer + local winid = util.buf_find_win(bufnr) + local new_items = {} + local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, true) + local errors = {} + local exit_early = false + local prefixes = load_qf_prefixes(bufnr) + local ext_id_to_item_idx = vim.b[bufnr].qf_ext_id_to_item_idx + for i, line in ipairs(lines) do + (function() + local extmarks = util.get_lnum_extmarks(bufnr, i, line:len()) + assert(#extmarks <= 1, string.format("Found more than one extmark on line %d", i)) + local found_idx + if extmarks[1] then + found_idx = ext_id_to_item_idx[extmarks[1][1]] + end + + -- If we didn't find a match, the line was most likely added or reordered + if not found_idx then + add_qf_error( + bufnr, + i, + "quicker.nvim does not support adding or reordering quickfix items", + "DiagnosticError" + ) + if winid then + vim.api.nvim_win_set_cursor(winid, { i, 0 }) + end + exit_early = true + return + end + + -- Trim the filename off of the line + local idx = string.find(line, display.EM_QUAD, 1, true) + if not idx then + add_qf_error( + bufnr, + i, + "The delimiter between filename and text has been deleted. Undo, delete line, or :Refresh.", + "DiagnosticError" + ) + if winid then + vim.api.nvim_win_set_cursor(winid, { i, 0 }) + end + exit_early = true + return + end + local text = line:sub(idx + display.EM_QUAD_LEN) + + local item = qf_list.items[found_idx] + if item.bufnr ~= 0 and item.lnum ~= 0 then + if not vim.api.nvim_buf_is_loaded(item.bufnr) then + vim.fn.bufload(item.bufnr) + end + local src_line = vim.api.nvim_buf_get_lines(item.bufnr, item.lnum - 1, item.lnum, false)[1] + + -- add the whitespace prefix back to the parsed line text + if config.trim_leading_whitespace == "common" then + text = (prefixes[item.bufnr] or "") .. text + elseif config.trim_leading_whitespace == "all" and src_line then + text = src_line:match("^%s*") .. text + end + + if src_line and text ~= src_line then + if text:gsub("^%s*", "") == src_line:gsub("^%s*", "") then + -- If they only disagree in their leading whitespace, just take the changes after the + -- whitespace and assume that the whitespace hasn't changed + text = src_line:match("^%s*") .. text:gsub("^%s*", "") + end + end + + local text_edit, err = get_text_edit(item, text, src_line) + if text_edit then + local chng_err = add_change(item.bufnr, text_edit) + if chng_err then + add_qf_error(bufnr, i, chng_err, "DiagnosticError") + if winid then + vim.api.nvim_win_set_cursor(winid, { i, 0 }) + end + exit_early = true + return + end + elseif err then + table.insert(new_items, item) + errors[#new_items] = line + return + end + end + + -- add item to future qflist + item.text = text + table.insert(new_items, item) + end)() + if exit_early then + vim.schedule(function() + vim.bo[bufnr].modified = true + end) + return + end + end + + ---@type table + local buf_was_modified = {} + for _, buf in ipairs(vim.api.nvim_list_bufs()) do + buf_was_modified[buf] = vim.bo[buf].modified + end + local autosave = config.edit.autosave + local num_applied = 0 + local modified_bufs = {} + for chg_buf, text_edits in pairs(changes) do + modified_bufs[chg_buf] = true + num_applied = num_applied + #text_edits + vim.lsp.util.apply_text_edits(text_edits, chg_buf, "utf-8") + local was_modified = buf_was_modified[chg_buf] + local should_save = autosave == true or (autosave == "unmodified" and not was_modified) + -- Autosave changed buffers if they were not modified before + if should_save then + vim.api.nvim_buf_call(chg_buf, function() + vim.cmd.update({ mods = { emsg_silent = true, noautocmd = true } }) + end) + end + end + if num_applied > 0 then + local num_files = vim.tbl_count(modified_bufs) + local num_errors = vim.tbl_count(errors) + if num_errors > 0 then + local total = num_errors + num_applied + vim.notify( + string.format( + "Applied %d/%d %s in %d %s", + num_applied, + total, + plural(total, "change"), + num_files, + plural(num_files, "file") + ), + vim.log.levels.WARN + ) + else + vim.notify( + string.format( + "Applied %d %s in %d %s", + num_applied, + plural(num_applied, "change"), + num_files, + plural(num_files, "file") + ), + vim.log.levels.INFO + ) + end + end + + local view + if winid then + view = vim.api.nvim_win_call(winid, function() + return vim.fn.winsaveview() + end) + end + if loclist_win then + vim.fn.setloclist( + loclist_win, + {}, + "r", + { items = new_items, title = qf_list.title, context = qf_list.context } + ) + else + vim.fn.setqflist( + {}, + "r", + { items = new_items, title = qf_list.title, context = qf_list.context } + ) + end + if winid and view then + vim.api.nvim_win_call(winid, function() + vim.fn.winrestview(view) + end) + end + + -- Schedule this so it runs after the save completes, and the buffer will be correctly marked as modified + if not vim.tbl_isempty(errors) then + vim.schedule(function() + -- Mark the lines with changes that could not be applied + for lnum, new_text in pairs(errors) do + replace_qf_line(bufnr, lnum, new_text) + local item = new_items[lnum] + local src_line = vim.api.nvim_buf_get_lines(item.bufnr, item.lnum - 1, item.lnum, false)[1] + add_qf_error(bufnr, lnum, src_line) + if winid and vim.api.nvim_win_is_valid(winid) then + vim.api.nvim_win_set_cursor(winid, { lnum, 0 }) + end + end + end) + + -- Notify user that some changes could not be applied + local cnt = vim.tbl_count(errors) + local change_text = cnt == 1 and "change" or "changes" + vim.notify( + string.format( + "%d %s could not be applied due to conflicts in the source buffer. Please :Refresh and try again.", + cnt, + change_text + ), + vim.log.levels.ERROR + ) + end +end + +-- TODO add support for undo past last change + +---@param bufnr integer +function M.setup_editor(bufnr) + local aug = vim.api.nvim_create_augroup("quicker", { clear = false }) + local loclist_win + vim.api.nvim_buf_call(bufnr, function() + local ok, qf = pcall(vim.fn.getloclist, 0, { filewinid = 0 }) + if ok and qf.filewinid and qf.filewinid ~= 0 then + loclist_win = qf.filewinid + end + end) + + -- Set a name for the buffer so we can save it + local bufname = string.format("quickfix-%d", bufnr) + if vim.api.nvim_buf_get_name(bufnr) == "" then + vim.api.nvim_buf_set_name(bufnr, bufname) + end + vim.bo[bufnr].modifiable = true + + vim.api.nvim_create_autocmd("BufWriteCmd", { + desc = "quicker.nvim apply changes on write", + group = aug, + buffer = bufnr, + nested = true, + callback = function(args) + save_changes(args.buf, loclist_win) + vim.bo[args.buf].modified = false + end, + }) +end + +return M diff --git a/lua/quicker/follow.lua b/lua/quicker/follow.lua new file mode 100644 index 0000000..d5500a2 --- /dev/null +++ b/lua/quicker/follow.lua @@ -0,0 +1,84 @@ +local util = require("quicker.util") +local M = {} + +M.seek_to_position = function() + if util.is_open(0) then + local qf_list = vim.fn.getloclist(0, { winid = 0, items = 0 }) + local new_pos = M.calculate_pos(qf_list.items) + if new_pos then + M.set_pos(qf_list.winid, new_pos) + end + end + + if util.is_open() then + local qf_list = vim.fn.getqflist({ winid = 0, items = 0 }) + local new_pos = M.calculate_pos(qf_list.items) + if new_pos then + M.set_pos(qf_list.winid, new_pos) + end + end +end + +---Calculate the current buffer/cursor location in the quickfix list +---@param list QuickFixItem[] +---@return nil|integer +M.calculate_pos = function(list) + if vim.bo.buftype ~= "" then + return + end + local bufnr = vim.api.nvim_get_current_buf() + local cursor = vim.api.nvim_win_get_cursor(0) + local lnum, col = cursor[1], cursor[2] + 1 + local prev_lnum = -1 + local prev_col = -1 + local found_buf = false + local ret + for i, entry in ipairs(list) do + if entry.bufnr ~= bufnr then + if found_buf then + return ret + end + else + found_buf = true + + -- If we detect that the list isn't sorted, bail. + if + prev_lnum > -1 + and (entry.lnum < prev_lnum or (entry.lnum == prev_lnum and entry.col <= prev_col)) + then + return + end + + if prev_lnum == -1 or lnum > entry.lnum or (lnum == entry.lnum and col >= entry.col) then + ret = i + end + prev_lnum = entry.lnum + prev_col = entry.col + end + end + + return ret +end + +local timers = {} +---@param winid integer +---@param pos integer +M.set_pos = function(winid, pos) + local timer = timers[winid] + if timer then + timer:close() + end + timer = assert(vim.uv.new_timer()) + timers[winid] = timer + timer:start(10, 0, function() + timer:close() + timers[winid] = nil + vim.schedule(function() + if vim.api.nvim_win_is_valid(winid) then + pcall(vim.api.nvim_win_set_cursor, winid, { pos, 0 }) + end + end) + end) +end + +return M diff --git a/lua/quicker/fs.lua b/lua/quicker/fs.lua new file mode 100644 index 0000000..f0e94dc --- /dev/null +++ b/lua/quicker/fs.lua @@ -0,0 +1,98 @@ +local M = {} + +---@type boolean +M.is_windows = vim.uv.os_uname().version:match("Windows") + +M.is_mac = vim.uv.os_uname().sysname == "Darwin" + +M.is_linux = not M.is_windows and not M.is_mac + +---@type string +M.sep = M.is_windows and "\\" or "/" + +---@param ... string +M.join = function(...) + return table.concat({ ... }, M.sep) +end + +---Check if OS path is absolute +---@param dir string +---@return boolean +M.is_absolute = function(dir) + if M.is_windows then + return dir:match("^%a:\\") + else + return vim.startswith(dir, "/") + end +end + +M.abspath = function(path) + if not M.is_absolute(path) then + path = vim.fn.fnamemodify(path, ":p") + end + return path +end + +local home_dir = assert(vim.uv.os_homedir()) + +---@param path string +---@param relative_to? string Shorten relative to this path (default cwd) +---@return string +M.shorten_path = function(path, relative_to) + if not relative_to then + relative_to = vim.fn.getcwd() + end + local relpath + if M.is_subpath(relative_to, path) then + local idx = relative_to:len() + 1 + -- Trim the dividing slash if it's not included in relative_to + if not vim.endswith(relative_to, "/") and not vim.endswith(relative_to, "\\") then + idx = idx + 1 + end + relpath = path:sub(idx) + if relpath == "" then + relpath = "." + end + end + if M.is_subpath(home_dir, path) then + local homepath = "~" .. path:sub(home_dir:len() + 1) + if not relpath or homepath:len() < relpath:len() then + return homepath + end + end + return relpath or path +end + +--- Returns true if candidate is a subpath of root, or if they are the same path. +---@param root string +---@param candidate string +---@return boolean +M.is_subpath = function(root, candidate) + if candidate == "" then + return false + end + root = vim.fs.normalize(M.abspath(root)) + -- Trim trailing "/" from the root + if root:find("/", -1) then + root = root:sub(1, -2) + end + candidate = vim.fs.normalize(M.abspath(candidate)) + if M.is_windows then + root = root:lower() + candidate = candidate:lower() + end + if root == candidate then + return true + end + local prefix = candidate:sub(1, root:len()) + if prefix ~= root then + return false + end + + local candidate_starts_with_sep = candidate:find("/", root:len() + 1, true) == root:len() + 1 + local root_ends_with_sep = root:find("/", root:len(), true) == root:len() + + return candidate_starts_with_sep or root_ends_with_sep +end + +return M diff --git a/lua/quicker/highlight.lua b/lua/quicker/highlight.lua new file mode 100644 index 0000000..a7323aa --- /dev/null +++ b/lua/quicker/highlight.lua @@ -0,0 +1,222 @@ +local M = {} + +---@class quicker.TSHighlight +---@field [1] integer start_col +---@field [2] integer end_col +---@field [3] string highlight group + +local _cached_queries = {} +---@param lang string +---@return vim.treesitter.Query? +local function get_highlight_query(lang) + local query = _cached_queries[lang] + if query == nil then + query = vim.treesitter.query.get(lang, "highlights") or false + _cached_queries[lang] = query + end + if query then + return query + end +end + +---@param bufnr integer +---@param lnum integer +---@return quicker.TSHighlight[] +function M.buf_get_ts_highlights(bufnr, lnum) + local filetype = vim.bo[bufnr].filetype + if not filetype or filetype == "" then + filetype = vim.filetype.match({ buf = bufnr }) or "" + end + local lang = vim.treesitter.language.get_lang(filetype) or filetype + if lang == "" then + return {} + end + local ok, parser = pcall(vim.treesitter.get_parser, bufnr, lang) + if not ok or not parser then + return {} + end + + local row = lnum - 1 + if not parser:is_valid() then + parser:parse(true) + end + + local highlights = {} + parser:for_each_tree(function(tstree, tree) + if not tstree then + return + end + + local root_node = tstree:root() + local root_start_row, _, root_end_row, _ = root_node:range() + + -- Only worry about trees within the line range + if root_start_row > row or root_end_row < row then + return + end + + local query = get_highlight_query(tree:lang()) + + -- Some injected languages may not have highlight queries. + if not query then + return + end + + for capture, node, metadata in query:iter_captures(root_node, bufnr, row, root_end_row + 1) do + if capture == nil then + break + end + + local range = vim.treesitter.get_range(node, bufnr, metadata[capture]) + local start_row, start_col, _, end_row, end_col, _ = unpack(range) + if start_row > row then + break + end + local capture_name = query.captures[capture] + local hl = string.format("@%s.%s", capture_name, tree:lang()) + if end_row > start_row then + end_col = -1 + end + table.insert(highlights, { start_col, end_col, hl }) + end + end) + + return highlights +end + +---@class quicker.LSPHighlight +---@field [1] integer start_col +---@field [2] integer end_col +---@field [3] string highlight group +---@field [4] integer priority modifier + +-- We're accessing private APIs here. This could break in the future. +local STHighlighter = vim.lsp.semantic_tokens.__STHighlighter + +--- Copied from Neovim semantic_tokens.lua +--- Do a binary search of the tokens in the half-open range [lo, hi). +--- +--- Return the index i in range such that tokens[j].line < line for all j < i, and +--- tokens[j].line >= line for all j >= i, or return hi if no such index is found. +--- +---@private +local function lower_bound(tokens, line, lo, hi) + while lo < hi do + local mid = bit.rshift(lo + hi, 1) -- Equivalent to floor((lo + hi) / 2). + if tokens[mid].line < line then + lo = mid + 1 + else + hi = mid + end + end + return lo +end + +---@param bufnr integer +---@param lnum integer +---@return quicker.LSPHighlight[] +function M.buf_get_lsp_highlights(bufnr, lnum) + local highlighter = STHighlighter.active[bufnr] + if not highlighter then + return {} + end + local ft = vim.bo[bufnr].filetype + + local lsp_highlights = {} + for _, client in pairs(highlighter.client_state) do + local highlights = client.current_result.highlights + if highlights then + local idx = lower_bound(highlights, lnum - 1, 1, #highlights + 1) + for i = idx, #highlights do + local token = highlights[i] + + if token.line >= lnum then + break + end + + table.insert( + lsp_highlights, + { token.start_col, token.end_col, string.format("@lsp.type.%s.%s", token.type, ft), 0 } + ) + for modifier, _ in pairs(token.modifiers) do + table.insert( + lsp_highlights, + { token.start_col, token.end_col, string.format("@lsp.mod.%s.%s", modifier, ft), 1 } + ) + table.insert(lsp_highlights, { + token.start_col, + token.end_col, + string.format("@lsp.typemod.%s.%s.%s", token.type, modifier, ft), + 2, + }) + end + end + end + end + + return lsp_highlights +end + +---@param item QuickFixItem +---@param line string +---@return quicker.TSHighlight[] +M.get_heuristic_ts_highlights = function(item, line) + local filetype = vim.filetype.match({ buf = item.bufnr }) + if not filetype then + return {} + end + + local lang = vim.treesitter.language.get_lang(filetype) + if not lang then + return {} + end + + local has_parser, parser = pcall(vim.treesitter.get_string_parser, line, lang) + if not has_parser then + return {} + end + + local root = parser:parse(true)[1]:root() + local query = vim.treesitter.query.get(lang, "highlights") + if not query then + return {} + end + + local highlights = {} + for capture, node, metadata in query:iter_captures(root, line) do + if capture == nil then + break + end + + local range = vim.treesitter.get_range(node, line, metadata[capture]) + local start_row, start_col, _, end_row, end_col, _ = unpack(range) + local capture_name = query.captures[capture] + local hl = string.format("@%s.%s", capture_name, lang) + if end_row > start_row then + end_col = -1 + end + table.insert(highlights, { start_col, end_col, hl }) + end + + return highlights +end + +function M.set_highlight_groups() + if vim.tbl_isempty(vim.api.nvim_get_hl(0, { name = "QuickFixHeaderHard" })) then + vim.api.nvim_set_hl(0, "QuickFixHeaderHard", { link = "Delimiter", default = true }) + end + if vim.tbl_isempty(vim.api.nvim_get_hl(0, { name = "QuickFixHeaderSoft" })) then + vim.api.nvim_set_hl(0, "QuickFixHeaderSoft", { link = "Comment", default = true }) + end + if vim.tbl_isempty(vim.api.nvim_get_hl(0, { name = "QuickFixFilename" })) then + vim.api.nvim_set_hl(0, "QuickFixFilename", { link = "Directory", default = true }) + end + if vim.tbl_isempty(vim.api.nvim_get_hl(0, { name = "QuickFixFilenameInvalid" })) then + vim.api.nvim_set_hl(0, "QuickFixFilenameInvalid", { link = "Comment", default = true }) + end + if vim.tbl_isempty(vim.api.nvim_get_hl(0, { name = "QuickFixLineNr" })) then + vim.api.nvim_set_hl(0, "QuickFixLineNr", { link = "LineNr", default = true }) + end +end + +return M diff --git a/lua/quicker/init.lua b/lua/quicker/init.lua new file mode 100644 index 0000000..42ae32b --- /dev/null +++ b/lua/quicker/init.lua @@ -0,0 +1,189 @@ +local M = {} + +---@param opts? quicker.SetupOptions +local function setup(opts) + local config = require("quicker.config") + config.setup(opts) + + local aug = vim.api.nvim_create_augroup("quicker", { clear = true }) + vim.api.nvim_create_autocmd("FileType", { + pattern = "qf", + group = aug, + desc = "quicker.nvim set up quickfix mappings", + callback = function(args) + require("quicker.highlight").set_highlight_groups() + require("quicker.opts").set_opts(args.buf) + require("quicker.keys").set_keymaps(args.buf) + vim.api.nvim_buf_create_user_command(args.buf, "Refresh", function() + require("quicker.context").refresh() + end, { + desc = "Update the quickfix list with the current buffer text for each item", + }) + + if config.constrain_cursor then + require("quicker.cursor").constrain_cursor(args.buf) + end + + config.on_qf(args.buf) + end, + }) + vim.api.nvim_create_autocmd("ColorScheme", { + pattern = "*", + group = aug, + desc = "quicker.nvim set up quickfix highlight groups", + callback = function() + require("quicker.highlight").set_highlight_groups() + end, + }) + if config.edit.enabled then + vim.api.nvim_create_autocmd("BufReadPost", { + pattern = "quickfix", + group = aug, + desc = "quicker.nvim set up quickfix editing", + callback = function(args) + require("quicker.editor").setup_editor(args.buf) + end, + }) + end + if config.follow.enabled then + vim.api.nvim_create_autocmd({ "CursorMoved", "BufEnter" }, { + desc = "quicker.nvim scroll to nearest location in quickfix", + pattern = "*", + group = aug, + callback = function() + require("quicker.follow").seek_to_position() + end, + }) + end + + vim.o.quickfixtextfunc = "v:lua.require'quicker.display'.quickfixtextfunc" + + -- If the quickfix/loclist is already open, refresh it so the quickfixtextfunc will take effect. + -- This is required for lazy-loading to work properly. + local list = vim.fn.getqflist({ all = 0 }) + if not vim.tbl_isempty(list.items) then + vim.fn.setqflist({}, "r", list) + end + for _, winid in ipairs(vim.api.nvim_list_wins()) do + if vim.api.nvim_win_is_valid(winid) then + local llist = vim.fn.getloclist(winid, { all = 0 }) + if not vim.tbl_isempty(list.items) then + vim.fn.setloclist(winid, {}, "r", llist) + end + end + end +end + +M.setup = setup + +---Expand the context around the quickfix results. +---@param opts? quicker.ExpandOpts +---@note +--- If there are multiple quickfix items for the same line of a file, only the first +--- one will remain after calling expand(). +M.expand = function(opts) + return require("quicker.context").expand(opts) +end + +---Collapse the context around quickfix results, leaving only the `valid` items. +M.collapse = function() + return require("quicker.context").collapse() +end + +---Toggle the expanded context around the quickfix results. +---@param opts? quicker.ExpandOpts +M.toggle_expand = function(opts) + return require("quicker.context").toggle(opts) +end + +---Update the quickfix list with the current buffer text for each item. +---@param loclist_win? integer +---@param opts? quicker.RefreshOpts +M.refresh = function(loclist_win, opts) + return require("quicker.context").refresh(loclist_win, opts) +end + +---@param loclist_win? integer Check if loclist is open for the given window. If nil, check quickfix. +M.is_open = function(loclist_win) + return require("quicker.util").is_open(loclist_win) +end + +---@class quicker.OpenCmdMods: vim.api.keyset.parse_cmd.mods + +---@class (exact) quicker.OpenOpts +---@field loclist? boolean Toggle the loclist instead of the quickfix list +---@field focus? boolean Focus the quickfix window after toggling (default false) +---@field height? integer Height of the quickfix window when opened. Defaults to number of items in the list. +---@field min_height? integer Minimum height of the quickfix window. Default 4. +---@field max_height? integer Maximum height of the quickfix window. Default 10. +---@field open_cmd_mods? quicker.OpenCmdMods A table of modifiers for the quickfix or loclist open commands. + +---Toggle the quickfix or loclist window. +---@param opts? quicker.OpenOpts +M.toggle = function(opts) + ---@type {loclist: boolean, focus: boolean, height?: integer, min_height: integer, max_height: integer, open_cmd_mods?: quicker.OpenCmdMods} + opts = vim.tbl_deep_extend("keep", opts or {}, { + loclist = false, + focus = false, + min_height = 4, + max_height = 10, + open_cmd_mods = {}, + }) + local loclist_win = opts.loclist and 0 or nil + if M.is_open(loclist_win) then + M.close({ loclist = opts.loclist }) + else + M.open(opts) + end +end + +---Open the quickfix or loclist window. +---@param opts? quicker.OpenOpts +M.open = function(opts) + local util = require("quicker.util") + ---@type {loclist: boolean, focus: boolean, height?: integer, min_height: integer, max_height: integer, open_cmd_mods?: quicker.OpenCmdMods} + opts = vim.tbl_deep_extend("keep", opts or {}, { + loclist = false, + focus = false, + min_height = 4, + max_height = 10, + open_cmd_mods = {}, + }) + local height + if opts.loclist then + local ok, err = pcall(vim.cmd.lopen, { mods = opts.open_cmd_mods }) + if not ok then + vim.notify(err, vim.log.levels.ERROR) + return + end + height = #vim.fn.getloclist(0) + else + vim.cmd.copen({ mods = opts.open_cmd_mods }) + height = #vim.fn.getqflist() + end + + -- only set the height if the quickfix is not a full-height vsplit + if not util.is_full_height_vsplit(0) then + height = math.min(opts.max_height, math.max(opts.min_height, height)) + vim.api.nvim_win_set_height(0, height) + end + + if not opts.focus then + vim.cmd.wincmd({ args = { "p" } }) + end +end + +---@class (exact) quicker.CloseOpts +---@field loclist? boolean Close the loclist instead of the quickfix list + +---Close the quickfix or loclist window. +---@param opts? quicker.CloseOpts +M.close = function(opts) + if opts and opts.loclist then + vim.cmd.lclose() + else + vim.cmd.cclose() + end +end + +return M diff --git a/lua/quicker/keys.lua b/lua/quicker/keys.lua new file mode 100644 index 0000000..17ea331 --- /dev/null +++ b/lua/quicker/keys.lua @@ -0,0 +1,20 @@ +local config = require("quicker.config") + +local M = {} + +---@param bufnr integer +function M.set_keymaps(bufnr) + for _, defn in ipairs(config.keys) do + vim.keymap.set(defn.mode or "n", defn[1], defn[2], { + buffer = bufnr, + desc = defn.desc, + expr = defn.expr, + nowait = defn.nowait, + remap = defn.remap, + replace_keycodes = defn.replace_keycodes, + silent = defn.silent, + }) + end +end + +return M diff --git a/lua/quicker/opts.lua b/lua/quicker/opts.lua new file mode 100644 index 0000000..1cf77b6 --- /dev/null +++ b/lua/quicker/opts.lua @@ -0,0 +1,61 @@ +local config = require("quicker.config") +local util = require("quicker.util") + +local M = {} + +---@param bufnr integer +local function set_buf_opts(bufnr) + for k, v in pairs(config.opts) do + local opt_info = vim.api.nvim_get_option_info2(k, {}) + if opt_info.scope == "buf" then + local ok, err = pcall(vim.api.nvim_set_option_value, k, v, { buf = bufnr }) + if not ok then + vim.notify( + string.format("Error setting quickfix option %s = %s: %s", k, vim.inspect(v), err), + vim.log.levels.ERROR + ) + end + end + end +end + +---@param winid integer +local function set_win_opts(winid) + for k, v in pairs(config.opts) do + local opt_info = vim.api.nvim_get_option_info2(k, {}) + if opt_info.scope == "win" then + local ok, err = pcall(vim.api.nvim_set_option_value, k, v, { scope = "local", win = winid }) + if not ok then + vim.notify( + string.format("Error setting quickfix window option %s = %s: %s", k, vim.inspect(v), err), + vim.log.levels.ERROR + ) + end + end + end +end + +---@param bufnr integer +function M.set_opts(bufnr) + set_buf_opts(bufnr) + local winid = util.buf_find_win(bufnr) + if winid then + set_win_opts(winid) + else + local aug = vim.api.nvim_create_augroup("quicker", { clear = false }) + vim.api.nvim_create_autocmd("BufWinEnter", { + desc = "Set quickfix window options", + buffer = bufnr, + group = aug, + callback = function() + winid = util.buf_find_win(bufnr) + if winid then + set_win_opts(winid) + end + return winid ~= nil + end, + }) + end +end + +return M diff --git a/lua/quicker/util.lua b/lua/quicker/util.lua new file mode 100644 index 0000000..3794091 --- /dev/null +++ b/lua/quicker/util.lua @@ -0,0 +1,95 @@ +local M = {} + +---@param bufnr integer +---@return nil|integer +function M.buf_find_win(bufnr) + for _, winid in ipairs(vim.api.nvim_list_wins()) do + if vim.api.nvim_win_is_valid(winid) and vim.api.nvim_win_get_buf(winid) == bufnr then + return winid + end + end +end + +---@param loclist_win? integer Check if loclist is open for the given window. If nil, check quickfix. +M.is_open = function(loclist_win) + if loclist_win then + return vim.fn.getloclist(loclist_win or 0, { winid = 0 }).winid ~= 0 + else + return vim.fn.getqflist({ winid = 0 }).winid ~= 0 + end +end + +---@param winid nil|integer +---@return nil|"c"|"l" +M.get_win_type = function(winid) + if not winid or winid == 0 then + winid = vim.api.nvim_get_current_win() + end + local info = vim.fn.getwininfo(winid)[1] + if info.quickfix == 0 then + return nil + elseif info.loclist == 0 then + return "c" + else + return "l" + end +end + +---@param item QuickFixItem +---@return QuickFixUserData +M.get_user_data = function(item) + if type(item.user_data) == "table" then + return item.user_data + else + return {} + end +end + +---Get valid location extmarks for a line in the quickfix +---@param bufnr integer +---@param lnum integer +---@param line_len? integer how long this particular line is +---@param ns? integer namespace of extmarks +---@return table[] extmarks +M.get_lnum_extmarks = function(bufnr, lnum, line_len, ns) + if not ns then + ns = vim.api.nvim_create_namespace("quicker_locations") + end + if not line_len then + local line = vim.api.nvim_buf_get_lines(0, lnum - 1, lnum, true)[1] + line_len = line:len() + end + local extmarks = vim.api.nvim_buf_get_extmarks( + bufnr, + ns, + { lnum - 1, 0 }, + { lnum - 1, line_len }, + { details = true } + ) + return vim.tbl_filter(function(mark) + return not mark[4].invalid + end, extmarks) +end + +---Return true if the window is a full-height leaf window +---@param winid? integer +---@return boolean +M.is_full_height_vsplit = function(winid) + if not winid or winid == 0 then + winid = vim.api.nvim_get_current_win() + end + local layout = vim.fn.winlayout() + -- If the top layout is not vsplit, then it's not a vertical leaf + if layout[1] ~= "row" then + return false + end + for _, v in ipairs(layout[2]) do + if v[1] == "leaf" and v[2] == winid then + return true + end + end + + return false +end + +return M diff --git a/run_tests.sh b/run_tests.sh new file mode 100755 index 0000000..f7b5bab --- /dev/null +++ b/run_tests.sh @@ -0,0 +1,36 @@ +#!/bin/bash +set -e + +for arg in "$@"; do + shift + case "$arg" in + '--update') + export UPDATE_SNAPSHOTS=1 + ;; + *) + set -- "$@" "$arg" + ;; + esac +done + +mkdir -p ".testenv/config/nvim" +mkdir -p ".testenv/data/nvim" +mkdir -p ".testenv/state/nvim" +mkdir -p ".testenv/run/nvim" +mkdir -p ".testenv/cache/nvim" +PLUGINS=".testenv/data/nvim/site/pack/plugins/start" + +if [ ! -e "$PLUGINS/plenary.nvim" ]; then + git clone --depth=1 https://github.com/nvim-lua/plenary.nvim.git "$PLUGINS/plenary.nvim" +else + (cd "$PLUGINS/plenary.nvim" && git pull) +fi + +XDG_CONFIG_HOME=".testenv/config" \ + XDG_DATA_HOME=".testenv/data" \ + XDG_STATE_HOME=".testenv/state" \ + XDG_RUNTIME_DIR=".testenv/run" \ + XDG_CACHE_HOME=".testenv/cache" \ + nvim --headless -u tests/minimal_init.lua \ + -c "RunTests ${1-tests}" +echo "Success" diff --git a/scripts/generate.py b/scripts/generate.py new file mode 100755 index 0000000..e1ccded --- /dev/null +++ b/scripts/generate.py @@ -0,0 +1,102 @@ +import os +import os.path +import re +from typing import List + +from nvim_doc_tools import ( + Vimdoc, + VimdocSection, + generate_md_toc, + indent, + parse_directory, + read_section, + render_md_api2, + render_vimdoc_api2, + replace_section, +) + +HERE = os.path.dirname(__file__) +ROOT = os.path.abspath(os.path.join(HERE, os.path.pardir)) +README = os.path.join(ROOT, "README.md") +DOC = os.path.join(ROOT, "doc") +VIMDOC = os.path.join(DOC, "quicker.txt") + + +def add_md_link_path(path: str, lines: List[str]) -> List[str]: + ret = [] + for line in lines: + ret.append(re.sub(r"(\(#)", "(" + path + "#", line)) + return ret + + +def update_md_api(): + types = parse_directory(os.path.join(ROOT, "lua")) + funcs = types.files["quicker/init.lua"].functions + lines = ["\n"] + render_md_api2(funcs, types, 3)[:-1] # trim last newline + replace_section( + README, + r"^$", + r"^$", + lines, + ) + + +def update_options(): + option_lines = ["\n", "```lua\n"] + config_file = os.path.join(ROOT, "lua", "quicker", "config.lua") + option_lines = read_section(config_file, r"^\s*local default_config =", r"^}$") + option_lines.insert(0, 'require("quicker").setup({\n') + option_lines.insert(0, "```lua\n") + option_lines.extend(["})\n", "```\n", "\n"]) + replace_section( + README, + r"^$", + r"^$", + option_lines, + ) + + +def update_readme_toc(): + toc = ["\n"] + generate_md_toc(README, max_level=1) + ["\n"] + replace_section( + README, + r"^$", + r"^$", + toc, + ) + + +def gen_options_vimdoc() -> VimdocSection: + section = VimdocSection("Options", "quicker-options", ["\n", ">lua\n"]) + config_file = os.path.join(ROOT, "lua", "quicker", "config.lua") + option_lines = read_section(config_file, r"^\s*local default_config =", r"^}$") + option_lines.insert(0, 'require("quicker").setup({\n') + option_lines.extend(["})\n"]) + section.body.extend(indent(option_lines, 4)) + section.body.append("<\n") + return section + + +def generate_vimdoc(): + doc = Vimdoc("quicker.txt", "quicker") + types = parse_directory(os.path.join(ROOT, "lua")) + funcs = types.files["quicker/init.lua"].functions + doc.sections.extend( + [ + gen_options_vimdoc(), + VimdocSection( + "API", "quicker-api", render_vimdoc_api2("quicker", funcs, types) + ), + ] + ) + + with open(VIMDOC, "w", encoding="utf-8") as ofile: + ofile.writelines(doc.render()) + + +def main() -> None: + """Update the README""" + update_md_api() + update_options() + update_readme_toc() + generate_vimdoc() diff --git a/scripts/main.py b/scripts/main.py new file mode 100755 index 0000000..4dffddf --- /dev/null +++ b/scripts/main.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python +import argparse +import os +import sys + +HERE = os.path.dirname(__file__) +ROOT = os.path.abspath(os.path.join(HERE, os.path.pardir)) +DOC = os.path.join(ROOT, "doc") + + +def main() -> None: + """Generate docs""" + sys.path.append(HERE) + parser = argparse.ArgumentParser(description=main.__doc__) + parser.add_argument("command", choices=["generate", "lint"]) + args = parser.parse_args() + if args.command == "generate": + import generate + + generate.main() + elif args.command == "lint": + from nvim_doc_tools import lint_md_links + + files = [os.path.join(ROOT, "README.md")] + [ + os.path.join(DOC, file) for file in os.listdir(DOC) if file.endswith(".md") + ] + lint_md_links.main(ROOT, files) + + +if __name__ == "__main__": + main() diff --git a/scripts/requirements.txt b/scripts/requirements.txt new file mode 100644 index 0000000..2c6271f --- /dev/null +++ b/scripts/requirements.txt @@ -0,0 +1,4 @@ +pyparsing==3.0.9 +black +isort +mypy diff --git a/syntax/qf.vim b/syntax/qf.vim new file mode 100644 index 0000000..8a19536 --- /dev/null +++ b/syntax/qf.vim @@ -0,0 +1,7 @@ +if exists('b:current_syntax') + finish +endif + +syn match QuickFixText /^.*/ + +let b:current_syntax = 'qf' diff --git a/tests/context_spec.lua b/tests/context_spec.lua new file mode 100644 index 0000000..4a4ef5a --- /dev/null +++ b/tests/context_spec.lua @@ -0,0 +1,134 @@ +local quicker = require("quicker") +local test_util = require("tests.test_util") + +describe("context", function() + after_each(function() + test_util.reset_editor() + end) + + it("expand results", function() + local first = test_util.make_tmp_file("expand_1.txt", 10) + local second = test_util.make_tmp_file("expand_2.txt", 10) + local first_buf = vim.fn.bufadd(first) + local second_buf = vim.fn.bufadd(second) + vim.fn.setqflist({ + { + bufnr = first_buf, + text = "line 2", + lnum = 2, + valid = 1, + }, + { + bufnr = first_buf, + text = "line 8", + lnum = 8, + valid = 1, + }, + { + bufnr = second_buf, + text = "line 4", + lnum = 4, + valid = 1, + }, + }) + vim.cmd.copen() + test_util.assert_snapshot(0, "expand_1") + + vim.api.nvim_win_set_cursor(0, { 3, 0 }) + quicker.expand() + test_util.assert_snapshot(0, "expand_2") + -- Cursor stays on the same item + assert.equals(12, vim.api.nvim_win_get_cursor(0)[1]) + vim.api.nvim_win_set_cursor(0, { 14, 0 }) + + -- Expanding again will produce the same result + quicker.expand() + test_util.assert_snapshot(0, "expand_2") + assert.equals(14, vim.api.nvim_win_get_cursor(0)[1]) + + -- Expanding again will produce the same result + quicker.expand({ add_to_existing = true }) + test_util.assert_snapshot(0, "expand_3") + + -- Collapsing will return to the original state + quicker.collapse() + test_util.assert_snapshot(0, "expand_1") + assert.equals(3, vim.api.nvim_win_get_cursor(0)[1]) + end) + + it("expand loclist results", function() + local bufnr = vim.fn.bufadd(test_util.make_tmp_file("expand_loclist.txt", 10)) + vim.fn.setloclist(0, { + { + bufnr = bufnr, + text = "line 2", + lnum = 2, + valid = 1, + }, + }) + vim.cmd.lopen() + quicker.expand() + test_util.assert_snapshot(0, "expand_loclist") + end) + + it("expand when items missing bufnr", function() + local bufnr = vim.fn.bufadd(test_util.make_tmp_file("expand_missing.txt", 10)) + vim.fn.setqflist({ + { + bufnr = bufnr, + text = "line 2", + lnum = 2, + valid = 1, + }, + { + text = "Valid line with no bufnr", + lnum = 4, + valid = 1, + }, + { + bufnr = bufnr, + text = "Invalid line with a bufnr", + lnum = 5, + valid = 0, + }, + { + text = "Invalid line with no bufnr", + lnum = 6, + valid = 0, + }, + }) + vim.cmd.copen() + quicker.expand() + -- The last three lines should be stripped after expansion + test_util.assert_snapshot(0, "expand_missing") + end) + + it("expand removes duplicate line entries", function() + local bufnr = vim.fn.bufadd(test_util.make_tmp_file("expand_dupe.txt", 10)) + vim.fn.setqflist({ + { + bufnr = bufnr, + text = "line 2", + lnum = 2, + valid = 1, + }, + { + bufnr = bufnr, + text = "line 3", + lnum = 3, + valid = 1, + }, + { + bufnr = bufnr, + text = "line 3", + lnum = 3, + valid = 1, + }, + }) + vim.cmd.copen() + test_util.assert_snapshot(0, "expand_dupe_1") + + quicker.expand() + test_util.assert_snapshot(0, "expand_dupe_2") + end) +end) diff --git a/tests/display_spec.lua b/tests/display_spec.lua new file mode 100644 index 0000000..c3404ad --- /dev/null +++ b/tests/display_spec.lua @@ -0,0 +1,145 @@ +require("plenary.async").tests.add_to_env() +local config = require("quicker.config") +local test_util = require("tests.test_util") + +local sleep = require("plenary.async.util").sleep + +a.describe("display", function() + after_each(function() + test_util.reset_editor() + end) + + it("renders quickfix items", function() + vim.fn.setqflist({ + { + bufnr = vim.fn.bufadd("README.md"), + text = "text", + lnum = 5, + valid = 1, + }, + { + filename = "README.md", + text = "text", + lnum = 10, + col = 0, + end_col = 4, + nr = 3, + type = "E", + valid = 1, + }, + { + module = "mod", + bufnr = vim.fn.bufadd("README.md"), + text = "text", + valid = 1, + }, + { + bufnr = vim.fn.bufadd("README.md"), + text = "text", + valid = 0, + }, + { + bufnr = vim.fn.bufadd("README.md"), + lnum = 1, + text = "", + valid = 0, + }, + }) + vim.cmd.copen() + test_util.assert_snapshot(0, "display_1") + end) + + a.it("truncates long filenames", function() + config.max_filename_width = function() + return 10 + end + local bufnr = vim.fn.bufadd(test_util.make_tmp_file(string.rep("f", 10) .. ".txt", 10)) + vim.fn.setqflist({ + { + bufnr = bufnr, + text = "text", + lnum = 5, + valid = 1, + }, + }) + vim.cmd.copen() + -- Wait for highlights to be applied + sleep(50) + test_util.assert_snapshot(0, "display_long_1") + end) + + a.it("renders minimal line when no filenames in results", function() + vim.fn.setqflist({ + { + text = "text", + }, + }) + vim.cmd.copen() + -- Wait for highlights to be applied + sleep(50) + test_util.assert_snapshot(0, "display_minimal_1") + end) + + a.it("sets signs for diagnostics", function() + local bufnr = vim.fn.bufadd(test_util.make_tmp_file("sign_test.txt", 10)) + vim.fn.setqflist({ + { + bufnr = bufnr, + text = "text", + lnum = 1, + type = "E", + valid = 1, + }, + { + bufnr = bufnr, + text = "text", + lnum = 2, + type = "W", + valid = 1, + }, + { + bufnr = bufnr, + text = "text", + lnum = 3, + type = "I", + valid = 1, + }, + { + bufnr = bufnr, + text = "text", + lnum = 4, + type = "H", + valid = 1, + }, + { + bufnr = bufnr, + text = "text", + lnum = 5, + type = "N", + valid = 1, + }, + }) + vim.cmd.copen() + + -- Wait for highlights to be applied + sleep(50) + local ns = vim.api.nvim_create_namespace("quicker_highlights") + local marks = vim.api.nvim_buf_get_extmarks(0, ns, 0, -1, { type = "sign" }) + assert.equals(5, #marks) + local expected = { + { "DiagnosticSignError", config.type_icons.E }, + { "DiagnosticSignWarn", config.type_icons.W }, + { "DiagnosticSignInfo", config.type_icons.I }, + { "DiagnosticSignHint", config.type_icons.H }, + { "DiagnosticSignHint", config.type_icons.N }, + } + for i, mark_data in ipairs(marks) do + local extmark_id, row = mark_data[1], mark_data[2] + local mark = vim.api.nvim_buf_get_extmark_by_id(0, ns, extmark_id, { details = true }) + local hl_group, icon = unpack(expected[i]) + assert.equals(i - 1, row) + assert.equals(hl_group, mark[3].sign_hl_group) + assert.equals(icon, mark[3].sign_text) + end + end) +end) diff --git a/tests/editor_spec.lua b/tests/editor_spec.lua new file mode 100644 index 0000000..0999508 --- /dev/null +++ b/tests/editor_spec.lua @@ -0,0 +1,347 @@ +local config = require("quicker.config") +local display = require("quicker.display") +local quicker = require("quicker") +local test_util = require("tests.test_util") + +---@param lnum integer +---@param line string +local function replace_text(lnum, line) + local prev_line = vim.api.nvim_buf_get_lines(0, lnum - 1, lnum, true)[1] + local idx = prev_line:find(display.EM_QUAD, 1, true) + vim.api.nvim_buf_set_text(0, lnum - 1, idx + display.EM_QUAD_LEN - 1, lnum - 1, -1, { line }) +end + +---@param lnum integer +local function del_line(lnum) + vim.cmd.normal({ args = { string.format("%dggdd", lnum) }, bang = true }) +end + +local function wait_virt_text() + vim.wait(10, function() + return false + end) +end + +describe("editor", function() + after_each(function() + test_util.reset_editor() + end) + + it("can edit one line in file", function() + vim.cmd.edit({ args = { test_util.make_tmp_file("edit_1.txt", 10) } }) + local bufnr = vim.api.nvim_get_current_buf() + vim.fn.setqflist({ + { + bufnr = bufnr, + text = "line 2", + lnum = 2, + }, + }) + vim.cmd.copen() + wait_virt_text() + replace_text(1, "new text") + vim.cmd.write() + test_util.assert_snapshot(bufnr, "edit_1") + end) + + it("can edit across multiple files", function() + local bufnr = vim.fn.bufadd(test_util.make_tmp_file("edit_multiple_1.txt", 10)) + vim.fn.bufload(bufnr) + local buf2 = vim.fn.bufadd(test_util.make_tmp_file("edit_multiple_2.txt", 10)) + vim.fn.bufload(buf2) + vim.fn.setqflist({ + { + bufnr = bufnr, + text = "line 2", + lnum = 2, + }, + { + bufnr = bufnr, + text = "line 9", + lnum = 9, + }, + { + bufnr = buf2, + text = "line 5", + lnum = 5, + }, + }) + vim.cmd.copen() + quicker.expand() + wait_virt_text() + replace_text(2, "new text") + replace_text(3, "some text") + replace_text(7, "other text") + replace_text(11, "final text") + local last_line = vim.api.nvim_buf_line_count(0) + vim.api.nvim_win_set_cursor(0, { last_line, 0 }) + vim.cmd.write() + test_util.assert_snapshot(0, "edit_multiple_qf") + test_util.assert_snapshot(bufnr, "edit_multiple_1") + test_util.assert_snapshot(buf2, "edit_multiple_2") + -- We should keep the cursor position + assert.equals(last_line, vim.api.nvim_win_get_cursor(0)[1]) + end) + + it("can expand then edit expanded line", function() + local bufnr = vim.fn.bufadd(test_util.make_tmp_file("edit_expanded.txt", 10)) + vim.fn.bufload(bufnr) + vim.fn.setqflist({ + { + bufnr = bufnr, + text = "line 2", + lnum = 2, + }, + }) + vim.cmd.copen() + quicker.expand() + wait_virt_text() + replace_text(1, "first") + replace_text(2, "second") + replace_text(3, "third") + vim.cmd.write() + test_util.assert_snapshot(bufnr, "edit_expanded") + test_util.assert_snapshot(0, "edit_expanded_qf") + end) + + it("fails when source text is different", function() + vim.cmd.edit({ args = { test_util.make_tmp_file("edit_fail.txt", 10) } }) + local bufnr = vim.api.nvim_get_current_buf() + vim.fn.setqflist({ + { + bufnr = bufnr, + text = "buzz buzz", + lnum = 2, + }, + }) + vim.cmd.copen() + wait_virt_text() + replace_text(1, "new text") + test_util.with(function() + local notify = vim.notify + ---@diagnostic disable-next-line: duplicate-set-field + vim.notify = function() end + return function() + vim.notify = notify + end + end, function() + vim.cmd.write() + end) + test_util.assert_snapshot(bufnr, "edit_fail") + test_util.assert_snapshot(0, "edit_fail_qf") + end) + + it("can handle multiple qf items on same lnum", function() + local bufnr = vim.fn.bufadd(test_util.make_tmp_file("edit_dupe.txt", 10)) + vim.fn.bufload(bufnr) + vim.fn.setqflist({ + { + bufnr = bufnr, + text = "line 2", + lnum = 2, + col = 0, + }, + { + bufnr = bufnr, + text = "line 2", + lnum = 2, + col = 3, + }, + }) + vim.cmd.copen() + wait_virt_text() + replace_text(1, "first") + replace_text(2, "second") + vim.cmd.write() + test_util.assert_snapshot(bufnr, "edit_dupe") + test_util.assert_snapshot(0, "edit_dupe_qf") + + -- If only one of them has a change, it should go through + replace_text(1, "line 2") + replace_text(2, "second") + vim.cmd.write() + test_util.assert_snapshot(bufnr, "edit_dupe_2") + test_util.assert_snapshot(0, "edit_dupe_qf_2") + end) + + it("handles deleting lines (shrinks quickfix)", function() + local bufnr = vim.fn.bufadd(test_util.make_tmp_file("edit_delete.txt", 10)) + vim.fn.bufload(bufnr) + vim.fn.setqflist({ + { + bufnr = bufnr, + text = "line 2", + lnum = 2, + }, + { + bufnr = bufnr, + text = "line 3", + lnum = 3, + }, + { + bufnr = bufnr, + text = "line 6", + lnum = 6, + }, + }) + vim.cmd.copen() + wait_virt_text() + del_line(3) + del_line(2) + vim.cmd.write() + assert.are.same({ + { + bufnr = bufnr, + text = "line 2", + lnum = 2, + col = 0, + end_col = 0, + vcol = 0, + end_lnum = 0, + module = "", + nr = 0, + pattern = "", + type = "", + valid = 1, + }, + }, vim.fn.getqflist()) + end) + + it("handles loclist", function() + vim.cmd.edit({ args = { test_util.make_tmp_file("edit_ll.txt", 10) } }) + local bufnr = vim.api.nvim_get_current_buf() + vim.fn.setloclist(0, { + { + bufnr = bufnr, + text = "line 2", + lnum = 2, + }, + }) + vim.cmd.lopen() + wait_virt_text() + replace_text(1, "new text") + vim.cmd.write() + test_util.assert_snapshot(bufnr, "edit_ll") + end) + + it("handles text that contains the delimiter", function() + vim.cmd.edit({ args = { test_util.make_tmp_file("edit_delim.txt", 10) } }) + local bufnr = vim.api.nvim_get_current_buf() + local line = "line 2 " .. config.borders.vert .. " text" + vim.api.nvim_buf_set_lines(bufnr, 1, 2, false, { line }) + vim.fn.setqflist({ + { + bufnr = bufnr, + text = line, + lnum = 2, + }, + }) + vim.cmd.copen() + wait_virt_text() + replace_text(1, line .. " " .. config.borders.vert .. " more text") + vim.cmd.write() + test_util.assert_snapshot(bufnr, "edit_delim") + end) + + it("can edit lines with trimmed common whitespace", function() + require("quicker.config").trim_leading_whitespace = "common" + vim.cmd.edit({ + args = { + test_util.make_tmp_file("edit_whitespace.txt", { + " line 1", + " line 2", + " line 3", + " line 4", + }), + }, + }) + local bufnr = vim.api.nvim_get_current_buf() + vim.fn.setqflist({ + { + bufnr = bufnr, + text = " line 2", + lnum = 2, + }, + { + bufnr = bufnr, + text = " line 3", + lnum = 3, + }, + }) + vim.cmd.copen() + wait_virt_text() + test_util.assert_snapshot(0, "edit_whitespace_qf") + replace_text(1, "foo") + replace_text(2, "bar") + vim.cmd.write() + test_util.assert_snapshot(bufnr, "edit_whitespace") + end) + + it("can edit lines with trimmed all whitespace", function() + require("quicker.config").trim_leading_whitespace = "all" + vim.cmd.edit({ + args = { + test_util.make_tmp_file("edit_whitespace.txt", { + " line 1", + " line 2", + " line 3", + " line 4", + }), + }, + }) + local bufnr = vim.api.nvim_get_current_buf() + vim.fn.setqflist({ + { + bufnr = bufnr, + text = " line 2", + lnum = 2, + }, + { + bufnr = bufnr, + text = " line 3", + lnum = 3, + }, + }) + vim.cmd.copen() + wait_virt_text() + test_util.assert_snapshot(0, "edit_all_whitespace_qf") + replace_text(1, "foo") + replace_text(2, "bar") + vim.cmd.write() + test_util.assert_snapshot(bufnr, "edit_all_whitespace") + end) + + it("can edit lines with untrimmed whitespace", function() + require("quicker.config").trim_leading_whitespace = false + vim.cmd.edit({ + args = { + test_util.make_tmp_file("edit_whitespace.txt", { + " line 1", + " line 2", + " line 3", + " line 4", + }), + }, + }) + local bufnr = vim.api.nvim_get_current_buf() + vim.fn.setqflist({ + { + bufnr = bufnr, + text = " line 2", + lnum = 2, + }, + { + bufnr = bufnr, + text = " line 3", + lnum = 3, + }, + }) + vim.cmd.copen() + wait_virt_text() + test_util.assert_snapshot(0, "edit_none_whitespace_qf") + replace_text(1, "foo") + replace_text(2, "bar") + vim.cmd.write() + test_util.assert_snapshot(bufnr, "edit_none_whitespace") + end) +end) diff --git a/tests/fs_spec.lua b/tests/fs_spec.lua new file mode 100644 index 0000000..2e1e54f --- /dev/null +++ b/tests/fs_spec.lua @@ -0,0 +1,20 @@ +local fs = require("quicker.fs") + +local home = os.getenv("HOME") +local cwd = vim.fn.getcwd() + +describe("fs", function() + it("shortens path", function() + assert.equals("~/bar/baz.txt", fs.shorten_path(home .. "/bar/baz.txt")) + assert.equals("bar/baz.txt", fs.shorten_path(cwd .. "/bar/baz.txt")) + assert.equals("/foo/bar.txt", fs.shorten_path("/foo/bar.txt")) + end) + + it("finds subpath", function() + assert.truthy(fs.is_subpath("/root", "/root/foo")) + assert.truthy(fs.is_subpath(cwd, "foo")) + assert.falsy(fs.is_subpath("/root", "/foo")) + assert.falsy(fs.is_subpath("/root", "/rooter/foo")) + assert.falsy(fs.is_subpath("/root", "/root/../foo")) + end) +end) diff --git a/tests/minimal_init.lua b/tests/minimal_init.lua new file mode 100644 index 0000000..486b213 --- /dev/null +++ b/tests/minimal_init.lua @@ -0,0 +1,16 @@ +vim.cmd([[set runtimepath+=.]]) + +vim.o.swapfile = false +vim.bo.swapfile = false +require("tests.test_util").reset_editor() + +-- TODO test highlighting (both highlight.lua module and adding them in display.lua) +-- TODO test syntax highlighting when customizing delimiter + +vim.api.nvim_create_user_command("RunTests", function(opts) + local path = opts.fargs[1] or "tests" + require("plenary.test_harness").test_directory( + path, + { minimal_init = "./tests/minimal_init.lua" } + ) +end, { nargs = "?" }) diff --git a/tests/opts_spec.lua b/tests/opts_spec.lua new file mode 100644 index 0000000..0732da2 --- /dev/null +++ b/tests/opts_spec.lua @@ -0,0 +1,52 @@ +local quicker = require("quicker") +local test_util = require("tests.test_util") + +describe("opts", function() + after_each(function() + test_util.reset_editor() + end) + + it("sets buffer opts", function() + quicker.setup({ + opts = { + buflisted = true, + bufhidden = "wipe", + cindent = true, + }, + }) + vim.fn.setqflist({ + { + bufnr = vim.fn.bufadd("README.md"), + text = "text", + lnum = 5, + valid = 1, + }, + }) + vim.cmd.copen() + assert.truthy(vim.bo.buflisted) + assert.equals("wipe", vim.bo.bufhidden) + assert.truthy(vim.bo.cindent) + end) + + it("sets window opts", function() + quicker.setup({ + opts = { + wrap = false, + number = true, + list = true, + }, + }) + vim.fn.setqflist({ + { + bufnr = vim.fn.bufadd("README.md"), + text = "text", + lnum = 5, + valid = 1, + }, + }) + vim.cmd.copen() + assert.falsy(vim.wo.wrap) + assert.truthy(vim.wo.number) + assert.truthy(vim.wo.list) + end) +end) diff --git a/tests/snapshots/display_1 b/tests/snapshots/display_1 new file mode 100644 index 0000000..270a164 --- /dev/null +++ b/tests/snapshots/display_1 @@ -0,0 +1,5 @@ +README.md ┃ 5┃text +README.md ┃10┃text +mod  ┃ ┃text +README.md ┃ ┃text +README.md ┃ 1┃ \ No newline at end of file diff --git a/tests/snapshots/display_long_1 b/tests/snapshots/display_long_1 new file mode 100644 index 0000000..d585beb --- /dev/null +++ b/tests/snapshots/display_long_1 @@ -0,0 +1 @@ +…ffffffff.txt ┃ 5┃text \ No newline at end of file diff --git a/tests/snapshots/display_minimal_1 b/tests/snapshots/display_minimal_1 new file mode 100644 index 0000000..46190e7 --- /dev/null +++ b/tests/snapshots/display_minimal_1 @@ -0,0 +1 @@ + ┃text \ No newline at end of file diff --git a/tests/snapshots/edit_1 b/tests/snapshots/edit_1 new file mode 100644 index 0000000..a18ed5a --- /dev/null +++ b/tests/snapshots/edit_1 @@ -0,0 +1,10 @@ +line 1 +new text +line 3 +line 4 +line 5 +line 6 +line 7 +line 8 +line 9 +line 10 \ No newline at end of file diff --git a/tests/snapshots/edit_all_whitespace b/tests/snapshots/edit_all_whitespace new file mode 100644 index 0000000..998c877 --- /dev/null +++ b/tests/snapshots/edit_all_whitespace @@ -0,0 +1,4 @@ + line 1 + foo + bar + line 4 \ No newline at end of file diff --git a/tests/snapshots/edit_all_whitespace_qf b/tests/snapshots/edit_all_whitespace_qf new file mode 100644 index 0000000..baf8533 --- /dev/null +++ b/tests/snapshots/edit_all_whitespace_qf @@ -0,0 +1,2 @@ +tests/tmp/edit_whitespace.txt ┃ 2┃line 2 +tests/tmp/edit_whitespace.txt ┃ 3┃line 3 \ No newline at end of file diff --git a/tests/snapshots/edit_delim b/tests/snapshots/edit_delim new file mode 100644 index 0000000..75a9e7f --- /dev/null +++ b/tests/snapshots/edit_delim @@ -0,0 +1,10 @@ +line 1 +line 2 ┃ text ┃ more text +line 3 +line 4 +line 5 +line 6 +line 7 +line 8 +line 9 +line 10 \ No newline at end of file diff --git a/tests/snapshots/edit_dupe b/tests/snapshots/edit_dupe new file mode 100644 index 0000000..b3f56ae --- /dev/null +++ b/tests/snapshots/edit_dupe @@ -0,0 +1,10 @@ +line 1 +line 2 +line 3 +line 4 +line 5 +line 6 +line 7 +line 8 +line 9 +line 10 \ No newline at end of file diff --git a/tests/snapshots/edit_dupe_2 b/tests/snapshots/edit_dupe_2 new file mode 100644 index 0000000..3ae9ccc --- /dev/null +++ b/tests/snapshots/edit_dupe_2 @@ -0,0 +1,10 @@ +line 1 +second +line 3 +line 4 +line 5 +line 6 +line 7 +line 8 +line 9 +line 10 \ No newline at end of file diff --git a/tests/snapshots/edit_dupe_qf b/tests/snapshots/edit_dupe_qf new file mode 100644 index 0000000..7e01207 --- /dev/null +++ b/tests/snapshots/edit_dupe_qf @@ -0,0 +1,2 @@ +tests/tmp/edit_dupe.txt ┃ 2┃first +tests/tmp/edit_dupe.txt ┃ 2┃second \ No newline at end of file diff --git a/tests/snapshots/edit_dupe_qf_2 b/tests/snapshots/edit_dupe_qf_2 new file mode 100644 index 0000000..1acfd8e --- /dev/null +++ b/tests/snapshots/edit_dupe_qf_2 @@ -0,0 +1,2 @@ +tests/tmp/edit_dupe.txt ┃ 2┃line 2 +tests/tmp/edit_dupe.txt ┃ 2┃second \ No newline at end of file diff --git a/tests/snapshots/edit_expanded b/tests/snapshots/edit_expanded new file mode 100644 index 0000000..afc39ad --- /dev/null +++ b/tests/snapshots/edit_expanded @@ -0,0 +1,10 @@ +first +second +third +line 4 +line 5 +line 6 +line 7 +line 8 +line 9 +line 10 \ No newline at end of file diff --git a/tests/snapshots/edit_expanded_qf b/tests/snapshots/edit_expanded_qf new file mode 100644 index 0000000..991dd06 --- /dev/null +++ b/tests/snapshots/edit_expanded_qf @@ -0,0 +1,4 @@ +  ┃ 1┃first +tests/tmp/edit_expanded.txt ┃ 2┃second +  ┃ 3┃third +  ┃ 4┃line 4 \ No newline at end of file diff --git a/tests/snapshots/edit_fail b/tests/snapshots/edit_fail new file mode 100644 index 0000000..b3f56ae --- /dev/null +++ b/tests/snapshots/edit_fail @@ -0,0 +1,10 @@ +line 1 +line 2 +line 3 +line 4 +line 5 +line 6 +line 7 +line 8 +line 9 +line 10 \ No newline at end of file diff --git a/tests/snapshots/edit_fail_qf b/tests/snapshots/edit_fail_qf new file mode 100644 index 0000000..3eb9f10 --- /dev/null +++ b/tests/snapshots/edit_fail_qf @@ -0,0 +1 @@ +tests/tmp/edit_fail.txt ┃ 2┃new text \ No newline at end of file diff --git a/tests/snapshots/edit_invalid b/tests/snapshots/edit_invalid new file mode 100644 index 0000000..386c994 --- /dev/null +++ b/tests/snapshots/edit_invalid @@ -0,0 +1 @@ + ┃ ┃new text diff --git a/tests/snapshots/edit_ll b/tests/snapshots/edit_ll new file mode 100644 index 0000000..a18ed5a --- /dev/null +++ b/tests/snapshots/edit_ll @@ -0,0 +1,10 @@ +line 1 +new text +line 3 +line 4 +line 5 +line 6 +line 7 +line 8 +line 9 +line 10 \ No newline at end of file diff --git a/tests/snapshots/edit_multiple_1 b/tests/snapshots/edit_multiple_1 new file mode 100644 index 0000000..765403a --- /dev/null +++ b/tests/snapshots/edit_multiple_1 @@ -0,0 +1,10 @@ +line 1 +new text +some text +line 4 +line 5 +line 6 +line 7 +line 8 +other text +line 10 \ No newline at end of file diff --git a/tests/snapshots/edit_multiple_2 b/tests/snapshots/edit_multiple_2 new file mode 100644 index 0000000..c988ea1 --- /dev/null +++ b/tests/snapshots/edit_multiple_2 @@ -0,0 +1,10 @@ +line 1 +line 2 +line 3 +line 4 +final text +line 6 +line 7 +line 8 +line 9 +line 10 \ No newline at end of file diff --git a/tests/snapshots/edit_multiple_qf b/tests/snapshots/edit_multiple_qf new file mode 100644 index 0000000..3de41ad --- /dev/null +++ b/tests/snapshots/edit_multiple_qf @@ -0,0 +1,15 @@ +  ┃ 1┃line 1 +tests/tmp/edit_multiple_1.txt ┃ 2┃new text +  ┃ 3┃some text +  ┃ 4┃line 4 +╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╂╌╌╂╌╌╌╌╌╌╌╌ +  ┃ 7┃line 7 +  ┃ 8┃line 8 +tests/tmp/edit_multiple_1.txt ┃ 9┃other text +  ┃10┃line 10 +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╋━━╋━━━━━━━━ +  ┃ 3┃line 3 +  ┃ 4┃line 4 +tests/tmp/edit_multiple_2.txt ┃ 5┃final text +  ┃ 6┃line 6 +  ┃ 7┃line 7 \ No newline at end of file diff --git a/tests/snapshots/edit_none_whitespace b/tests/snapshots/edit_none_whitespace new file mode 100644 index 0000000..4b592c2 --- /dev/null +++ b/tests/snapshots/edit_none_whitespace @@ -0,0 +1,4 @@ + line 1 +foo +bar + line 4 \ No newline at end of file diff --git a/tests/snapshots/edit_none_whitespace_qf b/tests/snapshots/edit_none_whitespace_qf new file mode 100644 index 0000000..5be47f1 --- /dev/null +++ b/tests/snapshots/edit_none_whitespace_qf @@ -0,0 +1,2 @@ +tests/tmp/edit_whitespace.txt ┃ 2┃ line 2 +tests/tmp/edit_whitespace.txt ┃ 3┃ line 3 \ No newline at end of file diff --git a/tests/snapshots/edit_whitespace b/tests/snapshots/edit_whitespace new file mode 100644 index 0000000..6a5ca4b --- /dev/null +++ b/tests/snapshots/edit_whitespace @@ -0,0 +1,4 @@ + line 1 + foo + bar + line 4 \ No newline at end of file diff --git a/tests/snapshots/edit_whitespace_qf b/tests/snapshots/edit_whitespace_qf new file mode 100644 index 0000000..e26d928 --- /dev/null +++ b/tests/snapshots/edit_whitespace_qf @@ -0,0 +1,2 @@ +tests/tmp/edit_whitespace.txt ┃ 2┃line 2 +tests/tmp/edit_whitespace.txt ┃ 3┃ line 3 \ No newline at end of file diff --git a/tests/snapshots/expand_1 b/tests/snapshots/expand_1 new file mode 100644 index 0000000..ab1901f --- /dev/null +++ b/tests/snapshots/expand_1 @@ -0,0 +1,3 @@ +tests/tmp/expand_1.txt ┃ 2┃line 2 +tests/tmp/expand_1.txt ┃ 8┃line 8 +tests/tmp/expand_2.txt ┃ 4┃line 4 \ No newline at end of file diff --git a/tests/snapshots/expand_2 b/tests/snapshots/expand_2 new file mode 100644 index 0000000..e0a2139 --- /dev/null +++ b/tests/snapshots/expand_2 @@ -0,0 +1,16 @@ +  ┃ 1┃line 1 +tests/tmp/expand_1.txt ┃ 2┃line 2 +  ┃ 3┃line 3 +  ┃ 4┃line 4 +╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╂╌╌╂╌╌╌╌╌╌╌╌ +  ┃ 6┃line 6 +  ┃ 7┃line 7 +tests/tmp/expand_1.txt ┃ 8┃line 8 +  ┃ 9┃line 9 +  ┃10┃line 10 +━━━━━━━━━━━━━━━━━━━━━━━╋━━╋━━━━━━━━ +  ┃ 2┃line 2 +  ┃ 3┃line 3 +tests/tmp/expand_2.txt ┃ 4┃line 4 +  ┃ 5┃line 5 +  ┃ 6┃line 6 \ No newline at end of file diff --git a/tests/snapshots/expand_3 b/tests/snapshots/expand_3 new file mode 100644 index 0000000..0a20a1c --- /dev/null +++ b/tests/snapshots/expand_3 @@ -0,0 +1,19 @@ +  ┃ 1┃line 1 +tests/tmp/expand_1.txt ┃ 2┃line 2 +  ┃ 3┃line 3 +  ┃ 4┃line 4 +  ┃ 5┃line 5 +  ┃ 6┃line 6 +  ┃ 7┃line 7 +tests/tmp/expand_1.txt ┃ 8┃line 8 +  ┃ 9┃line 9 +  ┃10┃line 10 +━━━━━━━━━━━━━━━━━━━━━━━╋━━╋━━━━━━━━ +  ┃ 1┃line 1 +  ┃ 2┃line 2 +  ┃ 3┃line 3 +tests/tmp/expand_2.txt ┃ 4┃line 4 +  ┃ 5┃line 5 +  ┃ 6┃line 6 +  ┃ 7┃line 7 +  ┃ 8┃line 8 \ No newline at end of file diff --git a/tests/snapshots/expand_dupe_1 b/tests/snapshots/expand_dupe_1 new file mode 100644 index 0000000..8e32cb4 --- /dev/null +++ b/tests/snapshots/expand_dupe_1 @@ -0,0 +1,3 @@ +tests/tmp/expand_dupe.txt ┃ 2┃line 2 +tests/tmp/expand_dupe.txt ┃ 3┃line 3 +tests/tmp/expand_dupe.txt ┃ 3┃line 3 \ No newline at end of file diff --git a/tests/snapshots/expand_dupe_2 b/tests/snapshots/expand_dupe_2 new file mode 100644 index 0000000..b51efa8 --- /dev/null +++ b/tests/snapshots/expand_dupe_2 @@ -0,0 +1,5 @@ +  ┃ 1┃line 1 +tests/tmp/expand_dupe.txt ┃ 2┃line 2 +tests/tmp/expand_dupe.txt ┃ 3┃line 3 +  ┃ 4┃line 4 +  ┃ 5┃line 5 \ No newline at end of file diff --git a/tests/snapshots/expand_loclist b/tests/snapshots/expand_loclist new file mode 100644 index 0000000..66a6207 --- /dev/null +++ b/tests/snapshots/expand_loclist @@ -0,0 +1,4 @@ +  ┃ 1┃line 1 +tests/tmp/expand_loclist.txt ┃ 2┃line 2 +  ┃ 3┃line 3 +  ┃ 4┃line 4 \ No newline at end of file diff --git a/tests/snapshots/expand_missing b/tests/snapshots/expand_missing new file mode 100644 index 0000000..f29a273 --- /dev/null +++ b/tests/snapshots/expand_missing @@ -0,0 +1,4 @@ +  ┃ 1┃line 1 +tests/tmp/expand_missing.txt ┃ 2┃line 2 +  ┃ 3┃line 3 +  ┃ 4┃line 4 \ No newline at end of file diff --git a/tests/snapshots/trim_all_whitespace b/tests/snapshots/trim_all_whitespace new file mode 100644 index 0000000..e664f80 --- /dev/null +++ b/tests/snapshots/trim_all_whitespace @@ -0,0 +1,2 @@ +tests/tmp/whitespace_1.txt ┃ 2┃line 2 +tests/tmp/whitespace_1.txt ┃ 3┃line 3 \ No newline at end of file diff --git a/tests/snapshots/trim_mixed_whitespace b/tests/snapshots/trim_mixed_whitespace new file mode 100644 index 0000000..f6464d4 --- /dev/null +++ b/tests/snapshots/trim_mixed_whitespace @@ -0,0 +1,2 @@ +tests/tmp/mixed_whitespace.txt ┃ 1┃ line 1 +tests/tmp/mixed_whitespace.txt ┃ 2┃ line 2 \ No newline at end of file diff --git a/tests/snapshots/trim_whitespace b/tests/snapshots/trim_whitespace new file mode 100644 index 0000000..49a6e20 --- /dev/null +++ b/tests/snapshots/trim_whitespace @@ -0,0 +1,2 @@ +tests/tmp/whitespace.txt ┃ 2┃line 2 +tests/tmp/whitespace.txt ┃ 3┃ line 3 \ No newline at end of file diff --git a/tests/snapshots/trim_whitespace_expanded b/tests/snapshots/trim_whitespace_expanded new file mode 100644 index 0000000..07b70d1 --- /dev/null +++ b/tests/snapshots/trim_whitespace_expanded @@ -0,0 +1,5 @@ +  ┃ 1┃ line 1 +tests/tmp/whitespace.txt ┃ 2┃line 2 +tests/tmp/whitespace.txt ┃ 3┃ line 3 +  ┃ 4┃ +  ┃ 5┃ line 4 \ No newline at end of file diff --git a/tests/test_util.lua b/tests/test_util.lua new file mode 100644 index 0000000..94b0a40 --- /dev/null +++ b/tests/test_util.lua @@ -0,0 +1,142 @@ +require("plenary.async").tests.add_to_env() +local M = {} + +local tmp_files = {} +M.reset_editor = function() + vim.cmd.tabonly({ mods = { silent = true } }) + for i, winid in ipairs(vim.api.nvim_tabpage_list_wins(0)) do + if i > 1 then + vim.api.nvim_win_close(winid, true) + end + end + vim.api.nvim_win_set_buf(0, vim.api.nvim_create_buf(false, true)) + for _, bufnr in ipairs(vim.api.nvim_list_bufs()) do + vim.api.nvim_buf_delete(bufnr, { force = true }) + end + vim.fn.setqflist({}) + vim.fn.setloclist(0, {}) + for _, filename in ipairs(tmp_files) do + vim.uv.fs_unlink(filename) + end + tmp_files = {} + + require("quicker").setup({ + header_length = function() + -- Make this deterministic so the snapshots are stable + return 8 + end, + }) +end + +---@param basename string +---@param lines integer|string[] +---@return string +M.make_tmp_file = function(basename, lines) + vim.fn.mkdir("tests/tmp", "p") + local filename = "tests/tmp/" .. basename + table.insert(tmp_files, filename) + local f = assert(io.open(filename, "w")) + if type(lines) == "table" then + for _, line in ipairs(lines) do + f:write(line .. "\n") + end + else + for i = 1, lines do + f:write("line " .. i .. "\n") + end + end + f:close() + return filename +end + +---@param name string +---@return string[] +local function load_snapshot(name) + local path = "tests/snapshots/" .. name + if vim.fn.filereadable(path) == 0 then + return {} + end + local f = assert(io.open(path, "r")) + local lines = {} + for line in f:lines() do + table.insert(lines, line) + end + f:close() + return lines +end + +---@param name string +---@param lines string[] +local function save_snapshot(name, lines) + vim.fn.mkdir("tests/snapshots", "p") + local path = "tests/snapshots/" .. name + local f = assert(io.open(path, "w")) + f:write(table.concat(lines, "\n")) + f:close() + return lines +end + +---@param bufnr integer +---@param name string +M.assert_snapshot = function(bufnr, name) + -- Wait for the virtual text extmarks to be set + if vim.bo[bufnr].filetype == "qf" then + vim.wait(10, function() + return false + end) + end + local util = require("quicker.util") + local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + + -- Add virtual text to lines + local headers = {} + local header_ns = vim.api.nvim_create_namespace("quicker_headers") + for i, v in ipairs(lines) do + local extmarks = util.get_lnum_extmarks(bufnr, i, v:len()) + assert(#extmarks <= 1, "Expected at most one extmark per line") + local mark = extmarks[1] + if mark then + local start_col = mark[3] + local data = mark[4] + local virt_text = table.concat( + vim.tbl_map(function(vt) + return vt[1] + end, data.virt_text), + "" + ) + lines[i] = v:sub(0, start_col) .. virt_text .. v:sub(start_col + 1) + + extmarks = util.get_lnum_extmarks(bufnr, i, v:len(), header_ns) + assert(#extmarks <= 1, "Expected at most one extmark per line") + mark = extmarks[1] + if mark and mark[4].virt_lines then + table.insert(headers, { i, mark[4].virt_lines[1][1][1] }) + end + end + end + + for i = #headers, 1, -1 do + local lnum, header = unpack(headers[i]) + table.insert(lines, lnum, header) + end + + if os.getenv("UPDATE_SNAPSHOTS") then + save_snapshot(name, lines) + else + local expected = load_snapshot(name) + assert.are.same(expected, lines) + end +end + +---@param context fun(): fun() +---@param fn fun() +M.with = function(context, fn) + local cleanup = context() + local ok, err = pcall(fn) + cleanup() + if not ok then + error(err) + end +end + +return M diff --git a/tests/whitespace_spec.lua b/tests/whitespace_spec.lua new file mode 100644 index 0000000..0933276 --- /dev/null +++ b/tests/whitespace_spec.lua @@ -0,0 +1,83 @@ +local quicker = require("quicker") +local test_util = require("tests.test_util") + +describe("whitespace", function() + before_each(function() + require("quicker.config").trim_leading_whitespace = "common" + end) + after_each(function() + test_util.reset_editor() + end) + + it("removes common leading whitespace from valid results", function() + local bufnr = vim.fn.bufadd(test_util.make_tmp_file("whitespace.txt", { + " line 1", + " line 2", + " line 3", + "", + " line 4", + })) + vim.fn.setqflist({ + { + bufnr = bufnr, + text = " line 2", + lnum = 2, + }, + { + bufnr = bufnr, + text = " line 3", + lnum = 3, + }, + }) + vim.cmd.copen() + test_util.assert_snapshot(0, "trim_whitespace") + quicker.expand() + test_util.assert_snapshot(0, "trim_whitespace_expanded") + end) + + it("handles mixed tabs and spaces", function() + local bufnr = vim.fn.bufadd(test_util.make_tmp_file("mixed_whitespace.txt", { + " line 1", + "\t\tline 2", + })) + vim.fn.setqflist({ + { + bufnr = bufnr, + text = " line 1", + lnum = 1, + }, + { + bufnr = bufnr, + text = "\t\tline 2", + lnum = 2, + }, + }) + vim.cmd.copen() + test_util.assert_snapshot(0, "trim_mixed_whitespace") + end) + + it("removes all leading whitespace", function() + require("quicker.config").trim_leading_whitespace = "all" + local bufnr = vim.fn.bufadd(test_util.make_tmp_file("whitespace_1.txt", { + " line 1", + " line 2", + " line 3", + "", + " line 4", + })) + vim.fn.setqflist({ + { + bufnr = bufnr, + text = " line 2", + lnum = 2, + }, + { + bufnr = bufnr, + text = " line 3", + lnum = 3, + }, + }) + vim.cmd.copen() + test_util.assert_snapshot(0, "trim_all_whitespace") + end) +end) -- cgit v1.2.3