From f34d908247bc4a061452aa903f681292e5162ab9 Mon Sep 17 00:00:00 2001 From: Steven Arcangeli Date: Fri, 26 Jul 2024 15:27:26 -0700 Subject: [PATCH] refactor: squash history for release --- .envrc | 2 + .github/ISSUE_TEMPLATE/bug_report.yml | 116 +++++ .github/ISSUE_TEMPLATE/feature_request.yml | 43 ++ .github/pre-commit | 3 + .github/pre-push | 11 + ...ation_remove_question_label_on_comment.yml | 16 + .../workflows/automation_request_review.yml | 27 ++ .github/workflows/install_nvim.sh | 12 + .github/workflows/tests.yml | 122 ++++++ .gitignore | 47 ++ .luacheckrc | 17 + .stylua.toml | 5 + Makefile | 31 ++ README.md | 374 +++++++++++++++- doc/quicker.txt | 200 +++++++++ lua/quicker/config.lua | 181 ++++++++ lua/quicker/context.lua | 260 ++++++++++++ lua/quicker/cursor.lua | 49 +++ lua/quicker/display.lua | 286 +++++++++++++ lua/quicker/editor.lua | 401 ++++++++++++++++++ lua/quicker/fs.lua | 98 +++++ lua/quicker/highlight.lua | 170 ++++++++ lua/quicker/init.lua | 150 +++++++ lua/quicker/keys.lua | 20 + lua/quicker/opts.lua | 61 +++ lua/quicker/syntax.lua | 27 ++ lua/quicker/util.lua | 29 ++ run_tests.sh | 36 ++ scripts/generate.py | 102 +++++ scripts/main.py | 31 ++ syntax/qf.vim | 7 + tests/context_spec.lua | 134 ++++++ tests/display_spec.lua | 108 +++++ tests/editor_spec.lua | 224 ++++++++++ tests/fs_spec.lua | 20 + tests/minimal_init.lua | 16 + tests/opts_spec.lua | 52 +++ tests/snapshots/display_1 | 4 + tests/snapshots/edit_1.txt | 10 + tests/snapshots/edit_delim.txt | 10 + tests/snapshots/edit_dupe.txt | 10 + tests/snapshots/edit_dupe_2.txt | 10 + tests/snapshots/edit_dupe_qf.txt | 2 + tests/snapshots/edit_dupe_qf_2.txt | 2 + tests/snapshots/edit_expanded.txt | 10 + tests/snapshots/edit_expanded_qf.txt | 4 + tests/snapshots/edit_fail.txt | 10 + tests/snapshots/edit_invalid.txt | 1 + tests/snapshots/edit_ll.txt | 10 + tests/snapshots/edit_multiple_1.txt | 10 + tests/snapshots/edit_multiple_2.txt | 10 + tests/snapshots/edit_multiple_qf.txt | 15 + 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/test_util.lua | 98 +++++ 60 files changed, 3757 insertions(+), 1 deletion(-) 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 .stylua.toml create mode 100644 Makefile 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/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/syntax.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 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/edit_1.txt create mode 100644 tests/snapshots/edit_delim.txt create mode 100644 tests/snapshots/edit_dupe.txt create mode 100644 tests/snapshots/edit_dupe_2.txt create mode 100644 tests/snapshots/edit_dupe_qf.txt create mode 100644 tests/snapshots/edit_dupe_qf_2.txt create mode 100644 tests/snapshots/edit_expanded.txt create mode 100644 tests/snapshots/edit_expanded_qf.txt create mode 100644 tests/snapshots/edit_fail.txt create mode 100644 tests/snapshots/edit_invalid.txt create mode 100644 tests/snapshots/edit_ll.txt create mode 100644 tests/snapshots/edit_multiple_1.txt create mode 100644 tests/snapshots/edit_multiple_2.txt create mode 100644 tests/snapshots/edit_multiple_qf.txt 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/test_util.lua diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..6a2e7a8 --- /dev/null +++ b/.envrc @@ -0,0 +1,2 @@ +layout python +python -c 'import pyparsing' 2> /dev/null || pip install pyparsing==3.0.9 black isort mypy 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..793dd8e --- /dev/null +++ b/.gitignore @@ -0,0 +1,47 @@ +# 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/ +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/.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/Makefile b/Makefile new file mode 100644 index 0000000..fe0ae4c --- /dev/null +++ b/Makefile @@ -0,0 +1,31 @@ +.PHONY: all doc test lint fastlint clean + +all: doc lint test + +doc: scripts/nvim_doc_tools + python scripts/main.py generate + python scripts/main.py lint + +test: + ./run_tests.sh + +# Update the snapshot files +update_snapshots: + ./run_tests.sh --update + +lint: scripts/nvim-typecheck-action fastlint + ./scripts/nvim-typecheck-action/typecheck.sh --workdir scripts/nvim-typecheck-action lua + +fastlint: scripts/nvim_doc_tools + 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: + rm -rf scripts/nvim_doc_tools scripts/nvim-typecheck-action diff --git a/README.md b/README.md index 719f45f..7df5add 100644 --- a/README.md +++ b/README.md @@ -1 +1,373 @@ -# quicker.nvim \ No newline at end of file +# quicker.nvim + +Improved UI and workflow for the Neovim quickfix + + + +- [Requirements](#requirements) +- [Features](#features) +- [Installation](#installation) +- [Setup](#setup) +- [Options](#options) +- [Highlights](#highlights) +- [API](#api) + - [setup(opts)](#setupopts) + - [expand(opts)](#expandopts) + - [collapse()](#collapse) + - [refresh(loclist_win)](#refreshloclist_win) + - [is_open(loclist_win)](#is_openloclist_win) + - [toggle(opts)](#toggleopts) + - [open(opts)](#openopts) + - [M.close(opts)](#mcloseopts) +- [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** \ +Before \ +Screenshot 2024-07-30 at 6 03 39 PM + +After (colorscheme: [Duskfox](https://github.com/EdenEast/nightfox.nvim/)) \ +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', + ---@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 highlights. Can be true, false, or "fast" + -- "fast" - only use highlights from buffers that are already parsed + treesitter = true, + -- Use LSP semantic token highlights + lsp = true, + }, + -- Options for customizing the display of the quickfix list + display = { + -- 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 = "┨", + }, + -- 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. The easiest way to change the styling is to use `:help winhighlight` in the setup `opts` option. + +- `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 +- `LineNr` - Used for the line number +- `Directory` - Used for the filename +- `DiagnosticSign*` - Used for the signs that display the quickfix error type + +## API + + + +### setup(opts) + +`setup(opts)` + +| Param | Type | Desc | | +| ----- | --------------------------- | ----------------------------------- | ---------------------------------------------------------------------------- | +| opts | `nil\|quicker.SetupOptions` | | | +| | on_qf | `nil\|fun(bufnr: number)` | Callback function to run any custom logic or keymaps for the quickfix buffer | +| | constrain_cursor | `nil\|boolean` | Keep the cursor to the right of the filename and lnum columns | +| | opts | `nil\|table` | Local options to set for quickfix | +| | keys | `nil\|quicker.Keymap[]` | Keymaps to set for the quickfix buffer | +| | use_default_opts | `nil\|boolean` | Set to false to disable the default options in `opts` | +| | highlight | `nil\|quicker.SetupHighlightConfig` | Configure syntax highlighting | +| | edit | `nil\|quicker.SetupEditConfig` | | +| | display | `nil\|quicker.SetupDisplayConfig` | Options for customizing the display of the quickfix list | + +### 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. + + +### refresh(loclist_win) + +`refresh(loclist_win)` \ +Update the quickfix list with the current buffer text for each item. + +| Param | Type | Desc | +| ----------- | -------------- | ---- | +| loclist_win | `nil\|integer` | | + +### 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(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. | + +### M.close(opts) + +`M.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. diff --git a/doc/quicker.txt b/doc/quicker.txt new file mode 100644 index 0000000..b454cb4 --- /dev/null +++ b/doc/quicker.txt @@ -0,0 +1,200 @@ +*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 highlights. Can be true, false, or "fast" + -- "fast" - only use highlights from buffers that are already parsed + treesitter = true, + -- Use LSP semantic token highlights + lsp = true, + }, + -- Options for customizing the display of the quickfix list + display = { + -- 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 = "┨", + }, + -- 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* + +setup({opts}) *quicker.setup* + + Parameters: + {opts} `nil|quicker.SetupOptions` + {on_qf} `nil|fun(bufnr: number)` Callback function to run + any custom logic or keymaps for the quickfix buffer + {constrain_cursor} `nil|boolean` Keep the cursor to the right of the + filename and lnum columns + {opts} `nil|table` Local options to set for + quickfix + {keys} `nil|quicker.Keymap[]` Keymaps to set for the + quickfix buffer + {use_default_opts} `nil|boolean` Set to false to disable the default + options in `opts` + {highlight} `nil|quicker.SetupHighlightConfig` Configure syntax + highlighting + {treesitter} `nil|boolean|"fast"` Enable treesitter syntax + highlighting. "fast" will only use highlights from + buffers that are already parsed + {lsp} `nil|boolean` Use LSP semantic token highlights + {edit} `nil|quicker.SetupEditConfig` + {enabled} `nil|boolean` + {autosave} `nil|boolean|"unmodified"` + {display} `nil|quicker.SetupDisplayConfig` Options for + customizing the display of the quickfix list + {type_icons} `nil|table` Map of quickfix item + type to icon + {borders} `nil|quicker.SetupBorders` Characters used for + drawing the borders + {vert} `nil|string` + {strong_header} `nil|string` Strong headers separate results + from different files + {strong_cross} `nil|string` + {strong_end} `nil|string` + {soft_header} `nil|string` Soft headers separate results + within the same file + {soft_cross} `nil|string` + {soft_end} `nil|string` + {max_filename_width} `nil|fun(): integer` Maximum width of the + filename column + {header_length} `nil|fun(type: "hard"|"soft", start_col: integer): integer` + How far the header should extend to the right + +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. + + +refresh({loclist_win}) *quicker.refresh* + Update the quickfix list with the current buffer text for each item. + + Parameters: + {loclist_win} `nil|integer` + +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({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. + +M.close({opts}) *quicker.M.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..def45cd --- /dev/null +++ b/lua/quicker/config.lua @@ -0,0 +1,181 @@ +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 highlights. Can be true, false, or "fast" + -- "fast" - only use highlights from buffers that are already parsed + treesitter = true, + -- Use LSP semantic token highlights + lsp = true, + }, + -- Options for customizing the display of the quickfix list + display = { + -- 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 = "┨", + }, + -- 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, + }, +} + +---@class quicker.Config +---@field on_qf fun(bufnr: number) +---@field constrain_cursor boolean +---@field opts table +---@field keys quicker.Keymap[] +---@field use_default_opts boolean +---@field highlight quicker.HighlightConfig +---@field edit quicker.EditConfig +---@field display quicker.DisplayConfig +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 constrain_cursor? boolean Keep the cursor to the right of the filename and lnum columns +---@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 highlight? quicker.SetupHighlightConfig Configure syntax highlighting +---@field edit? quicker.SetupEditConfig +---@field display? quicker.SetupDisplayConfig Options for customizing the display of the quickfix list + +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) + + -- TODO: option to remove whitespace at the beginning of the text? + for k, v in pairs(new_conf) do + M[k] = v + end + + -- Transparently convert a space into an em quad https://unicode-explorer.com/c/2001 + -- This is to keep it somewhat unique so it can still be used as an identifiable separator + for _, key in ipairs({ "vert", "strong_header", "soft_header" }) do + if M.display.borders[key] == " " then + M.display.borders[key] = " " + end + 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.DisplayConfig +---@field type_icons table +---@field borders quicker.Borders +---@field max_filename_width fun(): integer +---@field header_length fun(type: "hard"|"soft", start_col: integer): integer + +---@class (exact) quicker.SetupDisplayConfig +---@field type_icons? table Map of quickfix item type to icon +---@field borders? quicker.SetupBorders Characters used for drawing the borders +---@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 + +---@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|"fast" +---@field lsp boolean + +---@class (exact) quicker.SetupHighlightConfig +---@field treesitter? boolean|"fast" Enable treesitter syntax highlighting. "fast" will only use highlights from buffers that are already parsed +---@field lsp? boolean Use LSP semantic token highlights + +---@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..05f604c --- /dev/null +++ b/lua/quicker/context.lua @@ -0,0 +1,260 @@ +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 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 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 + + -- Insert the header + if prev_item and not overlaps_previous then + local filename = vim.fs.basename(vim.api.nvim_buf_get_name(item.bufnr)) + table.insert(items, { text = filename, valid = 0, user_data = { header = header_type } }) + 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 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 + table.insert(items, item) + if i == curpos then + newpos = #items + end + else + table.insert(items, { + bufnr = item.bufnr, + lnum = low + j, + text = line, + valid = 0, + user_data = { lnum = low + j }, + }) + 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 + 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 loclist_win? integer +function M.refresh(loclist_win) + 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 text = vim.api.nvim_buf_get_lines(item.bufnr, item.lnum - 1, item.lnum, false)[1] + if text then + item.text = text + 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..1b3c7d5 --- /dev/null +++ b/lua/quicker/cursor.lua @@ -0,0 +1,49 @@ +local config = require("quicker.config") +local M = {} + +local function constrain_cursor() + local b = config.display.borders + 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(b.vert, 1, true) + if not idx then + return + end + idx = line:find(b.vert, idx + b.vert:len(), true) + if not idx then + return + end + local min_col = idx + b.vert: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..adc9e0a --- /dev/null +++ b/lua/quicker/display.lua @@ -0,0 +1,286 @@ +local config = require("quicker.config") +local fs = require("quicker.fs") +local highlight = require("quicker.highlight") + +local M = {} + +---@class (exact) QuickFixUserData +---@field header? "hard"|"soft" +---@field lnum? integer + +---@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 item QuickFixItem +---@return QuickFixUserData +local function get_user_data(item) + if type(item.user_data) == "table" then + return item.user_data + else + return {} + end +end + +---@param type string +---@return string +local function get_icon(type) + return config.display.type_icons[type:upper()] or "U" +end + +local sign_highlight_map = { + E = "DiagnosticSignError", + W = "DiagnosticSignWarn", + I = "DiagnosticSignInfo", + H = "DiagnosticSignHint", + N = "DiagnosticSignHint", +} + +---@param item QuickFixItem +local function get_filename_from_item(item) + if item.valid == 1 then + 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.display.max_filename_width() + if path:len() > max_len then + path = "…" .. path:sub(path:len() - max_len - 1) + end + return path + else + return "" + end + 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(get_filename_from_item(item))) + end + + cached = { max_len + 1, #items } + _col_width_cache[id] = cached + end + return cached[1] +end + +---@param info QuickFixTextFuncInfo +local function add_qf_highlights(info) + local b = config.display.borders + local qf_list + if info.quickfix == 1 then + qf_list = vim.fn.getqflist({ id = info.id, items = 0, qfbufnr = 0 }) + else + qf_list = vim.fn.getloclist(info.winid, { id = info.id, items = 0, qfbufnr = 0 }) + end + if not qf_list.qfbufnr or qf_list.qfbufnr == 0 then + return + elseif info.end_idx < info.start_idx then + return + end + + local lines = vim.api.nvim_buf_get_lines(qf_list.qfbufnr, 0, -1, false) + local ns = vim.api.nvim_create_namespace("quicker_highlights") + if info.start_idx == 1 then + vim.api.nvim_buf_clear_namespace(qf_list.qfbufnr, ns, 0, -1) + else + vim.api.nvim_buf_clear_namespace(qf_list.qfbufnr, ns, info.start_idx - 1, -1) + end + local err_ns = vim.api.nvim_create_namespace("quicker_err") + vim.api.nvim_buf_clear_namespace(qf_list.qfbufnr, err_ns, 0, -1) + + for i = info.start_idx, info.end_idx do + ---@type QuickFixItem + local item = qf_list.items[i] + if item.bufnr ~= 0 then + if not vim.api.nvim_buf_is_loaded(item.bufnr) then + vim.fn.bufload(item.bufnr) + end + local line = lines[i] + local src_line = vim.api.nvim_buf_get_lines(item.bufnr, item.lnum - 1, item.lnum, false)[1] + -- Only add highlights if the text in the quickfix matches the source line + if item.text == src_line then + local offset = line:find(b.vert, 1, true) + offset = line:find(b.vert, offset + b.vert:len(), true) + b.vert:len() - 1 + + -- 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] + vim.api.nvim_buf_set_extmark(qf_list.qfbufnr, ns, i - 1, start_col + offset, { + 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(qf_list.qfbufnr, ns, i - 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 + + -- Set sign if item has a type + if item.type and item.type ~= "" then + vim.api.nvim_buf_set_extmark(qf_list.qfbufnr, ns, i - 1, 0, { + sign_text = get_icon(item.type), + sign_hl_group = sign_highlight_map[item.type:upper()], + invalidate = true, + }) + end + + local user_data = get_user_data(item) + if user_data.header == "hard" then + vim.api.nvim_buf_add_highlight(qf_list.qfbufnr, ns, "QuickFixHeaderHard", i - 1, 0, -1) + elseif user_data.header == "soft" then + vim.api.nvim_buf_add_highlight(qf_list.qfbufnr, ns, "QuickFixHeaderSoft", i - 1, 0, -1) + end + end +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 + +---@class QuickFixTextFuncInfo +---@field id integer +---@field start_idx integer +---@field end_idx integer +---@field winid integer +---@field quickfix 1|0 + +-- TODO when appending to a qflist, the alignment can be thrown off +---@param info QuickFixTextFuncInfo +function M.quickfixtextfunc(info) + local b = config.display.borders + local qf_list + local ret = {} + if info.quickfix == 1 then + qf_list = vim.fn.getqflist({ id = info.id, items = 0, qfbufnr = 0 }) + else + qf_list = vim.fn.getloclist(info.winid, { id = info.id, items = 0, qfbufnr = 0 }) + end + ---@type QuickFixItem[] + 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) + + for i = info.start_idx, info.end_idx do + local item = items[i] + local user_data = get_user_data(item) + if item.valid == 1 then + -- Matching line + local pieces = { rpad(get_filename_from_item(item), col_width) } + if item.lnum ~= 0 then + table.insert(pieces, lnum_fmt:format(item.lnum)) + else + table.insert(pieces, string.rep(" ", lnum_width)) + end + table.insert(pieces, item.text) + table.insert(ret, table.concat(pieces, b.vert)) + elseif user_data.header == "hard" then + -- Header when expanded QF list + local pieces = { + string.rep(b.strong_header, col_width), + b.strong_cross, + string.rep(b.strong_header, lnum_width), + } + local header_len = config.display.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(ret, table.concat(pieces, "")) + elseif user_data.header == "soft" then + -- Soft header when expanded QF list + local pieces = { + string.rep(b.soft_header, col_width), + b.soft_cross, + string.rep(b.soft_header, lnum_width), + } + local header_len = config.display.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(ret, table.concat(pieces, "")) + else + -- Non-matching line, either from context or normal QF results parsed with errorformat + local lnum = user_data.lnum or " " + local pieces = { string.rep(" ", col_width), lnum_fmt:format(lnum), item.text } + table.insert(ret, table.concat(pieces, b.vert)) + end + end + + -- If we just rendered the last item, add highlights + if info.end_idx == #items then + vim.schedule_wrap(add_qf_highlights)(info) + + -- If we have appended some items to the quickfix, we need to update qf_items (just the appended ones) + if qf_list.qfbufnr > 0 then + local stored_items = vim.b[qf_list.qfbufnr].qf_items or {} + for i = info.start_idx, info.end_idx do + stored_items[i] = items[i] + end + vim.b[qf_list.qfbufnr].qf_items = stored_items + 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..a153ce9 --- /dev/null +++ b/lua/quicker/editor.lua @@ -0,0 +1,401 @@ +local config = require("quicker.config") +local util = require("quicker.util") +local M = {} + +---@class (exact) quicker.ParsedLine +---@field filename? string +---@field lnum? integer +---@field text? string + +---@param line string +---@return quicker.ParsedLine +local function parse_line(line) + local pieces = vim.split(line, config.display.borders.vert) + if #pieces < 3 then + return { text = line } + end + -- If the buffer text contains the delimiter, we need to reassemble the text + local filename = vim.trim(pieces[1]) + local lnum = tonumber(pieces[2]) + local text = pieces[3] + if #pieces > 3 then + table.remove(pieces, 1) + table.remove(pieces, 1) + text = table.concat(pieces, config.display.borders.vert) + end + return { + filename = filename, + lnum = lnum, + text = text, + } +end + +---@param item QuickFixItem +---@param filename? string +---@return boolean +local function filename_match(item, filename) + if not filename or item.bufnr == 0 then + return false + else + local bufname = vim.api.nvim_buf_get_name(item.bufnr) + -- Trim off the leading "~" if this was a shortened path in the home dir + if vim.startswith(filename, "~") then + filename = filename:sub(2) + end + return vim.endswith(bufname, filename) + end +end + +---@param item QuickFixItem +---@return QuickFixUserData +local function get_user_data(item) + if type(item.user_data) == "table" then + return item.user_data + else + return {} + end +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.display.borders.vert, 1, true) + if col then + col = line:find(config.display.borders.vert, col + config.display.borders.vert:len(), true) + + config.display.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.display.type_icons.E, "DiagnosticSignError" } }, + virt_text_pos = "inline", + virt_lines = { + { + { string.rep(" ", offset), "Normal" }, + { "↳ ", "DiagnosticError" }, + { text, text_hl or "Normal" }, + }, + }, + }) +end + +---@param bufnr integer +---@param lnum integer +---@param text string +local function replace_text(bufnr, lnum, text) + local line = vim.api.nvim_buf_get_lines(bufnr, lnum - 1, lnum, false)[1] + local pieces = vim.split(line, config.display.borders.vert) + pieces[3] = text + pieces[4] = nil -- just in case there was a delimiter in the text + local new_line = table.concat(pieces, config.display.borders.vert) + vim.api.nvim_buf_set_lines(bufnr, lnum - 1, lnum, false, { new_line }) +end + +---@param items QuickFixItem[] +---@param start integer +---@param needle quicker.ParsedLine +---@return integer? next_start +local function find_next(items, start, needle) + -- If the line we're looking for has no filename, search for matching text + if not needle.filename then + -- Check if we're looking for a header + local header_types = {} + if vim.startswith(needle.text, config.display.borders.strong_header) then + table.insert(header_types, "hard") + elseif vim.startswith(needle.text, config.display.borders.soft_header) then + table.insert(header_types, "soft") + end + if not vim.tbl_isempty(header_types) then + for i = start, #items do + local item = items[i] + local user_data = get_user_data(item) + if vim.tbl_contains(header_types, user_data.header) then + return i + end + end + return + end + + for i = start, #items do + local item = items[i] + if item.bufnr == 0 and item.text == needle.text then + return i + end + end + return + end + + -- If we're looking for a line with a filename and no lnum check for filename + text + if needle.filename and not needle.lnum then + for i = start, #items do + local item = items[i] + if filename_match(item, needle.filename) and item.text == needle.text then + return i + end + end + return + end + + -- Search for filename and lnum match + for i = start, #items do + local item = items[i] + local lnum = item.lnum + if not lnum or lnum == 0 then + lnum = get_user_data(item).lnum + end + if filename_match(item, needle.filename) and lnum == needle.lnum then + return i + end + end +end + +---@param item QuickFixItem +---@param text string +---@return nil|table text_change +---@return nil|string error +local function get_text_edit(item, text) + local src_line = vim.api.nvim_buf_get_lines(item.bufnr, item.lnum - 1, item.lnum, false)[1] + if item.text == text then + return nil + elseif src_line ~= item.text then + return nil, "buffer text does not match source text" + end + + return { + newText = text, + range = { + start = { + line = item.lnum - 1, + character = 0, + }, + ["end"] = { + line = item.lnum - 1, + character = #src_line, + }, + }, + } +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 + + -- We save the quickfix items in BufReadPost, because they are used to create the quickfix + -- buffer text. However, if the source buffers are modified, the quickfix items will actually + -- update their lnum automatically next time we call getqflist. This is useful, but makes it + -- harder to match the buffer line to the quickfix item. So we use saved_items to match the line + -- to the item, and then map to the current quickfix item when performing the mutation. + ---@type QuickFixItem[] + local saved_items = vim.b[bufnr].qf_items + if not saved_items or #saved_items ~= #qf_list.items then + vim.notify("quicker.nvim: saved quickfix items are out of sync", vim.log.levels.WARN) + ---@type QuickFixItem[] + saved_items = qf_list.items + 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 item_idx = 1 + local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, true) + local errors = {} + local exit_early = false + for i, line in ipairs(lines) do + (function() + local parsed = parse_line(line) + local found_idx = find_next(saved_items, item_idx, parsed) + + -- 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 + item_idx = found_idx + 1 + + local item = qf_list.items[found_idx] + if item.bufnr ~= 0 and item.lnum ~= 0 then + local text_edit, err = get_text_edit(item, parsed.text) + 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] = parsed.text + return + end + end + + -- add item to future qflist + item.text = parsed.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 + for chg_buf, text_edits in pairs(changes) do + 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 + + 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_text(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, item.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) + + -- save the items for later + if loclist_win then + vim.b[bufnr].qf_items = vim.fn.getloclist(loclist_win) + else + vim.b[bufnr].qf_items = vim.fn.getqflist() + 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/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..42a01a2 --- /dev/null +++ b/lua/quicker/highlight.lua @@ -0,0 +1,170 @@ +local config = require("quicker.config") +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 config.highlight.treesitter ~= "fast" and 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 + +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 +end + +return M diff --git a/lua/quicker/init.lua b/lua/quicker/init.lua new file mode 100644 index 0000000..8c5a0ba --- /dev/null +++ b/lua/quicker/init.lua @@ -0,0 +1,150 @@ +local M = {} + +---@param opts? quicker.SetupOptions +M.setup = function(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 + + vim.o.quickfixtextfunc = "v:lua.require'quicker.display'.quickfixtextfunc" +end + +---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 + +---Update the quickfix list with the current buffer text for each item. +---@param loclist_win? integer +M.refresh = function(loclist_win) + return require("quicker.context").refresh(loclist_win) +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, { winid = 0 }).winid ~= 0 + else + return vim.fn.getqflist({ winid = 0 }).winid ~= 0 + end +end + +---@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. + +---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} + opts = vim.tbl_deep_extend("keep", opts or {}, { + loclist = false, + focus = false, + min_height = 4, + max_height = 10, + }) + 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) + ---@type {loclist: boolean, focus: boolean, height?: integer, min_height: integer, max_height: integer} + opts = vim.tbl_deep_extend("keep", opts or {}, { + loclist = false, + focus = false, + min_height = 4, + max_height = 10, + }) + local height = 0 + if opts.loclist then + local ok, err = pcall(vim.cmd.lopen) + if not ok then + vim.notify(err, vim.log.levels.ERROR) + return + end + height = #vim.fn.getloclist(0) + else + vim.cmd.copen() + height = #vim.fn.getqflist() + end + + height = math.min(opts.max_height, math.max(opts.min_height, height)) + vim.api.nvim_win_set_height(0, height) + + 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 +function M.close(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/syntax.lua b/lua/quicker/syntax.lua new file mode 100644 index 0000000..5ff7637 --- /dev/null +++ b/lua/quicker/syntax.lua @@ -0,0 +1,27 @@ +local config = require("quicker.config") + +local M = {} + +function M.set_syntax() + local v = config.display.borders.vert + local cmd = string.format( + [[ +syn match qfFileName /^[^%s]*/ nextgroup=qfSeparatorLeft +syn match qfSeparatorLeft /%s/ contained nextgroup=qfLineNr +syn match qfLineNr /[^%s]*/ contained nextgroup=qfSeparatorRight +syn match qfSeparatorRight '%s' contained + +hi def link qfFileName Directory +hi def link qfSeparatorLeft Delimiter +hi def link qfSeparatorRight Delimiter +hi def link qfLineNr LineNr +]], + v, + v, + v, + v + ) + vim.cmd(cmd) +end + +return M diff --git a/lua/quicker/util.lua b/lua/quicker/util.lua new file mode 100644 index 0000000..72a0324 --- /dev/null +++ b/lua/quicker/util.lua @@ -0,0 +1,29 @@ +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 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 + +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..810aeaa --- /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) + ["\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/syntax/qf.vim b/syntax/qf.vim new file mode 100644 index 0000000..64257d0 --- /dev/null +++ b/syntax/qf.vim @@ -0,0 +1,7 @@ +if exists('b:current_syntax') + finish +endif + +lua require('quicker.syntax').set_syntax() + +let b:current_syntax = 'qf' diff --git a/tests/context_spec.lua b/tests/context_spec.lua new file mode 100644 index 0000000..13f2bfc --- /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(14, vim.api.nvim_win_get_cursor(0)[1]) + vim.api.nvim_win_set_cursor(0, { 15, 0 }) + + -- Expanding again will produce the same result + quicker.expand() + test_util.assert_snapshot(0, "expand_2") + assert.equals(16, 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..d9806fb --- /dev/null +++ b/tests/display_spec.lua @@ -0,0 +1,108 @@ +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, + }, + }) + vim.cmd.copen() + test_util.assert_snapshot(0, "display_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(10) + 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.display.type_icons.E }, + { "DiagnosticSignWarn", config.display.type_icons.W }, + { "DiagnosticSignInfo", config.display.type_icons.I }, + { "DiagnosticSignHint", config.display.type_icons.H }, + { "DiagnosticSignHint", config.display.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..784cd34 --- /dev/null +++ b/tests/editor_spec.lua @@ -0,0 +1,224 @@ +local config = require("quicker.config") +local quicker = require("quicker") +local test_util = require("tests.test_util") + +---@param start_idx integer +---@param end_idx integer +---@param lines string[] +local function replace_text(start_idx, end_idx, lines) + local buflines = vim.api.nvim_buf_get_lines(0, start_idx, end_idx, false) + for i, line in ipairs(buflines) do + local pieces = vim.split(line, config.display.borders.vert) + pieces[3] = lines[i] + pieces[4] = nil -- just in case there was a delimiter in the text + buflines[i] = table.concat(pieces, config.display.borders.vert) + end + vim.api.nvim_buf_set_lines(0, start_idx, end_idx, false, buflines) +end + +describe("editor", function() + after_each(function() + test_util.reset_editor() + end) + + it("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() + replace_text(0, -1, { "new text" }) + vim.cmd.write() + test_util.assert_snapshot(bufnr, "edit_1.txt") + end) + + it("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() + replace_text(1, 3, { "new text", "some text" }) + replace_text(7, 8, { "other text" }) + replace_text(12, 13, { "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.txt") + test_util.assert_snapshot(bufnr, "edit_multiple_1.txt") + test_util.assert_snapshot(buf2, "edit_multiple_2.txt") + -- We should keep the cursor position + assert.equals(last_line, vim.api.nvim_win_get_cursor(0)[1]) + end) + + it("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() + replace_text(0, 3, { "first", "second", "third" }) + vim.cmd.write() + test_util.assert_snapshot(bufnr, "edit_expanded.txt") + test_util.assert_snapshot(0, "edit_expanded_qf.txt") + end) + + it("edit 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() + replace_text(0, -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.txt") + end) + + it("edit 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() + replace_text(0, -1, { "first", "second" }) + vim.cmd.write() + test_util.assert_snapshot(bufnr, "edit_dupe.txt") + test_util.assert_snapshot(0, "edit_dupe_qf.txt") + + -- If only one of them has a change, it should go through + replace_text(0, -1, { "line 2", "second" }) + vim.cmd.write() + test_util.assert_snapshot(bufnr, "edit_dupe_2.txt") + test_util.assert_snapshot(0, "edit_dupe_qf_2.txt") + end) + + it("edit 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() + vim.api.nvim_buf_set_lines(0, 0, -1, false, vim.api.nvim_buf_get_lines(0, 0, 1, false)) + 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("edit 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() + replace_text(0, -1, { "new text" }) + vim.cmd.write() + test_util.assert_snapshot(bufnr, "edit_ll.txt") + end) + + it("edit 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.display.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() + replace_text(0, -1, { line .. " " .. config.display.borders.vert .. " more text" }) + vim.cmd.write() + test_util.assert_snapshot(bufnr, "edit_delim.txt") + 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..cc59643 --- /dev/null +++ b/tests/snapshots/display_1 @@ -0,0 +1,4 @@ +README.md ┃ 5┃text +README.md ┃10┃text +mod ┃ ┃text + ┃ ┃text \ No newline at end of file diff --git a/tests/snapshots/edit_1.txt b/tests/snapshots/edit_1.txt new file mode 100644 index 0000000..a18ed5a --- /dev/null +++ b/tests/snapshots/edit_1.txt @@ -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_delim.txt b/tests/snapshots/edit_delim.txt new file mode 100644 index 0000000..75a9e7f --- /dev/null +++ b/tests/snapshots/edit_delim.txt @@ -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.txt b/tests/snapshots/edit_dupe.txt new file mode 100644 index 0000000..b3f56ae --- /dev/null +++ b/tests/snapshots/edit_dupe.txt @@ -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.txt b/tests/snapshots/edit_dupe_2.txt new file mode 100644 index 0000000..3ae9ccc --- /dev/null +++ b/tests/snapshots/edit_dupe_2.txt @@ -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.txt b/tests/snapshots/edit_dupe_qf.txt new file mode 100644 index 0000000..0ff0d2c --- /dev/null +++ b/tests/snapshots/edit_dupe_qf.txt @@ -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.txt b/tests/snapshots/edit_dupe_qf_2.txt new file mode 100644 index 0000000..04e22fa --- /dev/null +++ b/tests/snapshots/edit_dupe_qf_2.txt @@ -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.txt b/tests/snapshots/edit_expanded.txt new file mode 100644 index 0000000..afc39ad --- /dev/null +++ b/tests/snapshots/edit_expanded.txt @@ -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.txt b/tests/snapshots/edit_expanded_qf.txt new file mode 100644 index 0000000..23dd6e6 --- /dev/null +++ b/tests/snapshots/edit_expanded_qf.txt @@ -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.txt b/tests/snapshots/edit_fail.txt new file mode 100644 index 0000000..b3f56ae --- /dev/null +++ b/tests/snapshots/edit_fail.txt @@ -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_invalid.txt b/tests/snapshots/edit_invalid.txt new file mode 100644 index 0000000..386c994 --- /dev/null +++ b/tests/snapshots/edit_invalid.txt @@ -0,0 +1 @@ + ┃ ┃new text diff --git a/tests/snapshots/edit_ll.txt b/tests/snapshots/edit_ll.txt new file mode 100644 index 0000000..a18ed5a --- /dev/null +++ b/tests/snapshots/edit_ll.txt @@ -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.txt b/tests/snapshots/edit_multiple_1.txt new file mode 100644 index 0000000..765403a --- /dev/null +++ b/tests/snapshots/edit_multiple_1.txt @@ -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.txt b/tests/snapshots/edit_multiple_2.txt new file mode 100644 index 0000000..c988ea1 --- /dev/null +++ b/tests/snapshots/edit_multiple_2.txt @@ -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.txt b/tests/snapshots/edit_multiple_qf.txt new file mode 100644 index 0000000..c0e7493 --- /dev/null +++ b/tests/snapshots/edit_multiple_qf.txt @@ -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/expand_1 b/tests/snapshots/expand_1 new file mode 100644 index 0000000..e7e2407 --- /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..caec91c --- /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..8982850 --- /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..4c616b2 --- /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..200c882 --- /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..83512ad --- /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..6d27ec7 --- /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/test_util.lua b/tests/test_util.lua new file mode 100644 index 0000000..6c6f047 --- /dev/null +++ b/tests/test_util.lua @@ -0,0 +1,98 @@ +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({ + display = { + header_length = function() + -- Make this deterministic so the snapshots are stable + return 8 + end, + }, + }) +end + +---@param basename string +---@param lines integer +---@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")) + for i = 1, lines do + f:write("line " .. i .. "\n") + 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) + local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + 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