diff --git a/README.md b/README.md index d0bee16..696001d 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,10 @@ return { -- completion results. Example: "tg" on_off = "tg", }, + + -- The backend to use for searching. Defaults to "ripgrep". + -- "gitgrep" is available as a preview right now. + backend = "ripgrep", }, -- Show debug information in `:messages` that can help in diff --git a/integration-tests/MyTestDirectory.ts b/integration-tests/MyTestDirectory.ts index d8fe663..ce3a8ff 100644 --- a/integration-tests/MyTestDirectory.ts +++ b/integration-tests/MyTestDirectory.ts @@ -82,6 +82,10 @@ export const MyTestDirectorySchema = z.object({ name: z.literal("use_case_sensitive_search.lua"), type: z.literal("file"), }), + "use_gitgrep_backend.lua": z.object({ + name: z.literal("use_gitgrep_backend.lua"), + type: z.literal("file"), + }), "use_manual_mode.lua": z.object({ name: z.literal("use_manual_mode.lua"), type: z.literal("file"), @@ -176,6 +180,7 @@ export const testDirectoryFiles = z.enum([ "config-modifications/set_ignore_paths.lua", "config-modifications/use_additional_paths.lua", "config-modifications/use_case_sensitive_search.lua", + "config-modifications/use_gitgrep_backend.lua", "config-modifications/use_manual_mode.lua", "config-modifications/use_not_found_project_root.lua", "config-modifications", diff --git a/integration-tests/cypress/e2e/blink-ripgrep/basic_spec.cy.ts b/integration-tests/cypress/e2e/blink-ripgrep/backend_gitgrep_spec.cy.ts similarity index 59% rename from integration-tests/cypress/e2e/blink-ripgrep/basic_spec.cy.ts rename to integration-tests/cypress/e2e/blink-ripgrep/backend_gitgrep_spec.cy.ts index 02410a4..959183b 100644 --- a/integration-tests/cypress/e2e/blink-ripgrep/basic_spec.cy.ts +++ b/integration-tests/cypress/e2e/blink-ripgrep/backend_gitgrep_spec.cy.ts @@ -1,11 +1,27 @@ import { flavors } from "@catppuccin/palette" import { rgbify } from "@tui-sandbox/library/dist/src/client/color-utilities" +import type { NeovimContext } from "cypress/support/tui-sandbox" import { createGitReposToLimitSearchScope } from "./createGitReposToLimitSearchScope" +import { verifyGitGrepBackendWasUsedInTest } from "./verifyGitGrepBackendWasUsedInTest" -describe("the basics", () => { +type NeovimArguments = Parameters[0] + +function startNeovimWithGitBackend( + options: Partial, +): Cypress.Chainable { + if (!options) options = {} + options.startupScriptModifications = options.startupScriptModifications ?? [] + if (!options.startupScriptModifications.includes("use_gitgrep_backend.lua")) { + options.startupScriptModifications.push("use_gitgrep_backend.lua") + } + assert(options.startupScriptModifications.includes("use_gitgrep_backend.lua")) + return cy.startNeovim(options) +} + +describe("the GitGrepBackend", () => { it("shows words in other files as suggestions", () => { cy.visit("/") - cy.startNeovim().then((nvim) => { + startNeovimWithGitBackend({}).then((nvim) => { // wait until text on the start screen is visible cy.contains("If you see this text, Neovim is ready!") createGitReposToLimitSearchScope() @@ -38,7 +54,7 @@ describe("the basics", () => { it("allows invoking manually as a blink-cmp keymap", () => { cy.visit("/") - cy.startNeovim({ + startNeovimWithGitBackend({ startupScriptModifications: [ "use_manual_mode.lua", // make sure this is tested somewhere. it doesn't really belong to this @@ -66,7 +82,7 @@ describe("the basics", () => { it("can use an underscore (_) ca be used to trigger blink completions", () => { cy.visit("/") - cy.startNeovim({}).then(() => { + startNeovimWithGitBackend({}).then(() => { // wait until text on the start screen is visible cy.contains("If you see this text, Neovim is ready!") createGitReposToLimitSearchScope() @@ -85,57 +101,13 @@ describe("the basics", () => { }) }) - it("does not search in ignore_paths", () => { - // By default, the paths ignored via git and ripgrep are also automatically - // ignored by blink-ripgrep.nvim, without any extra features (this is a - // ripgrep feature). However, the user may want to ignore some paths from - // blink-ripgrep.nvim specifically. Here we test that feature. - cy.visit("/") - cy.startNeovim({ - filename: "limited/subproject/file1.lua", - startupScriptModifications: ["set_ignore_paths.lua"], - }).then((nvim) => { - // wait until text on the start screen is visible - cy.contains("This is text from file1.lua") - createGitReposToLimitSearchScope() - const ignorePath = nvim.dir.rootPathAbsolute + "/limited" - nvim.runLuaCode({ - luaCode: `_G.set_ignore_paths({ "${ignorePath}" })`, - }) - - // clear the current line and enter insert mode - cy.typeIntoTerminal("cc") - - // this will match text from ../../../test-environment/other-file.lua - // - // If the plugin works, this text should show up as a suggestion. - cy.typeIntoTerminal("hip") - - nvim - .runLuaCode({ - luaCode: `return _G.blink_ripgrep_invocations`, - }) - .should((result) => { - // ripgrep should only have been invoked once - expect(result.value).to.be.an("array") - expect(result.value).to.have.length(1) - - const invocations = (result.value as string[][])[0] - const invocation = invocations[0] - expect(invocation).to.eql("ignored") - }) - - cy.contains("Hippopotamus" + "234 (rg)").should("not.exist") - }) - }) - it("shows 5 lines around the match by default", () => { // The match context means the lines around the matched line. // We want to show context so that the user can see/remember where the match // was found. Although we don't explicitly show all the matches in the // project, this can still be very useful. cy.visit("/") - cy.startNeovim().then(() => { + startNeovimWithGitBackend({}).then(() => { cy.contains("If you see this text, Neovim is ready!") createGitReposToLimitSearchScope() @@ -161,26 +133,6 @@ describe("the basics", () => { }) }) - it("can use additional_paths to include additional files in the search", () => { - // By default, ripgrep allows using gitignore files to exclude files from - // the search. It works exactly like git does, and allows an intuitive way - // to exclude files. - cy.visit("/") - cy.startNeovim({ - filename: "limited/dir with spaces/file with spaces.txt", - startupScriptModifications: ["use_additional_paths.lua"], - }).then(() => { - // wait until text on the start screen is visible - cy.contains("this is file with spaces.txt") - createGitReposToLimitSearchScope() - cy.typeIntoTerminal("cc") - - // search for something that will be found in the additional words.txt file - cy.typeIntoTerminal("abas") - cy.contains("abased") - }) - }) - function assertMatchVisible( match: string, color?: typeof flavors.macchiato.colors.green.rgb, @@ -191,4 +143,114 @@ describe("the basics", () => { rgbify(color ?? flavors.macchiato.colors.green.rgb), ) } + + afterEach(() => { + verifyGitGrepBackendWasUsedInTest() + }) +}) + +describe("in debug mode", () => { + it("can execute the debug command in a shell", () => { + cy.visit("/") + startNeovimWithGitBackend({}).then((nvim) => { + // wait until text on the start screen is visible + cy.contains("If you see this text, Neovim is ready!") + createGitReposToLimitSearchScope() + + // clear the current line and enter insert mode + cy.typeIntoTerminal("cc") + + cy.typeIntoTerminal("spa") + cy.contains("spaceroni-macaroni") + + nvim.runExCommand({ command: "messages" }).then((result) => { + // make sure the logged command can be run in a shell + expect(result.value) + cy.log(result.value ?? "") + + cy.typeIntoTerminal("{esc}:term{enter}", { delay: 3 }) + + // get the current buffer name + nvim.runExCommand({ command: "echo expand('%')" }).then((bufname) => { + cy.log(bufname.value ?? "") + expect(bufname.value).to.contain("term://") + }) + + // start insert mode + cy.typeIntoTerminal("a") + + // Quickly send the text over instead of typing it out. Cypress is a + // bit slow when writing a lot of text. + nvim.runLuaCode({ + luaCode: `vim.api.nvim_feedkeys([[${result.value}]], "n", true)`, + }) + cy.typeIntoTerminal("{enter}") + + // The results will be 5-10 lines of jsonl. + // Somewhere in the results, we should see the match, if the search was + // successful. + cy.contains(`spaceroni-macaroni`) + }) + }) + }) + + it("highlights the search word when a new search is started", () => { + cy.visit("/") + startNeovimWithGitBackend({}).then((nvim) => { + // wait until text on the start screen is visible + cy.contains("If you see this text, Neovim is ready!") + createGitReposToLimitSearchScope() + + // clear the current line and enter insert mode + cy.typeIntoTerminal("cc") + + // debug mode should be on by default for all tests. Otherwise it doesn't + // make sense to test this, as nothing will be displayed. + nvim.runLuaCode({ + luaCode: `assert(require("blink-ripgrep").config.debug)`, + }) + + // this will match text from ../../../test-environment/other-file.lua + // + // If the plugin works, this text should show up as a suggestion. + cy.typeIntoTerminal("hip") + // the search should have been started for the prefix "hip" + cy.contains("hip").should( + "have.css", + "backgroundColor", + rgbify(flavors.macchiato.colors.flamingo.rgb), + ) + // + // blink is now in the Fuzzy(3) stage, and additional keypresses must not + // start a new ripgrep search. They must be used for filtering the + // results instead. + // https://cmp.saghen.dev/development/architecture.html#architecture + cy.contains("Hippopotamus" + "234 (rg)") // wait for blink to show up + cy.typeIntoTerminal("234") + + // wait for the highlight to disappear to test that too + cy.contains("hip").should( + "have.css", + "backgroundColor", + rgbify(flavors.macchiato.colors.base.rgb), + ) + + nvim + .runLuaCode({ + luaCode: `return _G.blink_ripgrep_invocations`, + }) + .should((result) => { + // ripgrep should only have been invoked once + expect(result.value).to.be.an("array") + expect(result.value).to.have.length(1) + }) + }) + }) + + // TODO add a test for "can clean up (kill) a previous search". This is too + // fast and currently needs to be timing based (👎🏻). + + afterEach(() => { + verifyGitGrepBackendWasUsedInTest() + }) }) diff --git a/integration-tests/cypress/e2e/blink-ripgrep/backend_ripgrep_spec.cy.ts b/integration-tests/cypress/e2e/blink-ripgrep/backend_ripgrep_spec.cy.ts new file mode 100644 index 0000000..b4bd1f1 --- /dev/null +++ b/integration-tests/cypress/e2e/blink-ripgrep/backend_ripgrep_spec.cy.ts @@ -0,0 +1,290 @@ +import { flavors } from "@catppuccin/palette" +import { rgbify } from "@tui-sandbox/library/dist/src/client/color-utilities" +import { createGitReposToLimitSearchScope } from "./createGitReposToLimitSearchScope" + +describe("the RipgrepBackend", () => { + it("shows words in other files as suggestions", () => { + cy.visit("/") + cy.startNeovim().then((nvim) => { + // wait until text on the start screen is visible + cy.contains("If you see this text, Neovim is ready!") + createGitReposToLimitSearchScope() + + // clear the current line and enter insert mode + cy.typeIntoTerminal("cc") + + // this will match text from ../../../test-environment/other-file.lua + // + // If the plugin works, this text should show up as a suggestion. + cy.typeIntoTerminal("hip") + cy.contains("Hippopotamus" + "234 (rg)") // wait for blink to show up + cy.typeIntoTerminal("234") + + // should show documentation with more details about the match + // + // should show the text for the matched line + // + // the text should also be syntax highlighted + cy.contains("was my previous password").should( + "have.css", + "color", + rgbify(flavors.macchiato.colors.green.rgb), + ) + + // should show the file name + cy.contains(nvim.dir.contents["other-file.lua"].name) + }) + }) + + it("does not search in ignore_paths", () => { + // By default, the paths ignored via git and ripgrep are also automatically + // ignored by blink-ripgrep.nvim, without any extra features (this is a + // ripgrep feature). However, the user may want to ignore some paths from + // blink-ripgrep.nvim specifically. Here we test that feature. + cy.visit("/") + cy.startNeovim({ + filename: "limited/subproject/file1.lua", + startupScriptModifications: ["set_ignore_paths.lua"], + }).then((nvim) => { + // wait until text on the start screen is visible + cy.contains("This is text from file1.lua") + createGitReposToLimitSearchScope() + const ignorePath = nvim.dir.rootPathAbsolute + "/limited" + nvim.runLuaCode({ + luaCode: `_G.set_ignore_paths({ "${ignorePath}" })`, + }) + + // clear the current line and enter insert mode + cy.typeIntoTerminal("cc") + + // this will match text from ../../../test-environment/other-file.lua + // + // If the plugin works, this text should show up as a suggestion. + cy.typeIntoTerminal("hip") + + nvim + .runLuaCode({ + luaCode: `return _G.blink_ripgrep_invocations`, + }) + .should((result) => { + // ripgrep should only have been invoked once + expect(result.value).to.be.an("array") + expect(result.value).to.have.length(1) + + const invocations = (result.value as string[][])[0] + const invocation = invocations[0] + expect(invocation).to.eql("ignored") + }) + + cy.contains("Hippopotamus" + "234 (rg)").should("not.exist") + }) + }) + + it("shows 5 lines around the match by default", () => { + // The match context means the lines around the matched line. + // We want to show context so that the user can see/remember where the match + // was found. Although we don't explicitly show all the matches in the + // project, this can still be very useful. + cy.visit("/") + cy.startNeovim().then(() => { + cy.contains("If you see this text, Neovim is ready!") + createGitReposToLimitSearchScope() + + cy.typeIntoTerminal("cc") + + // find a match that has more than 5 lines of context + cy.typeIntoTerminal("line_7") + + // we should now see lines 2-12 (default 5 lines of context around the match) + cy.contains(`"This is line 1"`).should("not.exist") + assertMatchVisible(`"This is line 2"`) + assertMatchVisible(`"This is line 3"`) + assertMatchVisible(`"This is line 4"`) + assertMatchVisible(`"This is line 5"`) + assertMatchVisible(`"This is line 6"`) + assertMatchVisible(`"This is line 7"`) // the match + assertMatchVisible(`"This is line 8"`) + assertMatchVisible(`"This is line 9"`) + assertMatchVisible(`"This is line 10"`) + assertMatchVisible(`"This is line 11"`) + assertMatchVisible(`"This is line 12"`) + cy.contains(`"This is line 13"`).should("not.exist") + }) + }) + + it("can use additional_paths to include additional files in the search", () => { + // By default, ripgrep allows using gitignore files to exclude files from + // the search. It works exactly like git does, and allows an intuitive way + // to exclude files. + cy.visit("/") + cy.startNeovim({ + filename: "limited/dir with spaces/file with spaces.txt", + startupScriptModifications: ["use_additional_paths.lua"], + }).then(() => { + // wait until text on the start screen is visible + cy.contains("this is file with spaces.txt") + createGitReposToLimitSearchScope() + cy.typeIntoTerminal("cc") + + // search for something that will be found in the additional words.txt file + cy.typeIntoTerminal("abas") + cy.contains("abased") + }) + }) + + function assertMatchVisible( + match: string, + color?: typeof flavors.macchiato.colors.green.rgb, + ) { + cy.contains(match).should( + "have.css", + "color", + rgbify(color ?? flavors.macchiato.colors.green.rgb), + ) + } +}) + +describe("in debug mode", () => { + it("can execute the debug command in a shell", () => { + cy.visit("/") + cy.startNeovim({ + // also test that the plugin can handle spaces in the file path + filename: "limited/dir with spaces/file with spaces.txt", + startupScriptModifications: ["use_additional_paths.lua"], + }).then((nvim) => { + // wait until text on the start screen is visible + cy.contains("this is file with spaces.txt") + nvim.runExCommand({ command: `!mkdir "%:h/.git"` }) + + // clear the current line and enter insert mode + cy.typeIntoTerminal("cc") + + cy.typeIntoTerminal("spa") + cy.contains("spaceroni-macaroni") + + nvim.runExCommand({ command: "messages" }).then((result) => { + // make sure the logged command can be run in a shell + expect(result.value) + cy.log(result.value ?? "") + + cy.typeIntoTerminal("{esc}:term{enter}", { delay: 3 }) + + // get the current buffer name + nvim.runExCommand({ command: "echo expand('%')" }).then((bufname) => { + cy.log(bufname.value ?? "") + expect(bufname.value).to.contain("term://") + }) + + // start insert mode + cy.typeIntoTerminal("a") + + // Quickly send the text over instead of typing it out. Cypress is a + // bit slow when writing a lot of text. + nvim.runLuaCode({ + luaCode: `vim.api.nvim_feedkeys([[${result.value}]], "n", true)`, + }) + cy.typeIntoTerminal("{enter}") + + // The results will be 5-10 lines of jsonl. + // Somewhere in the results, we should see the match, if the search was + // successful. + cy.contains(`spaceroni-macaroni`) + + // additional_paths should be used + cy.contains("words.txt") + }) + }) + }) + + it("highlights the search word when a new search is started", () => { + cy.visit("/") + cy.startNeovim({}).then((nvim) => { + // wait until text on the start screen is visible + cy.contains("If you see this text, Neovim is ready!") + createGitReposToLimitSearchScope() + + // clear the current line and enter insert mode + cy.typeIntoTerminal("cc") + + // debug mode should be on by default for all tests. Otherwise it doesn't + // make sense to test this, as nothing will be displayed. + nvim.runLuaCode({ + luaCode: `assert(require("blink-ripgrep").config.debug)`, + }) + + // this will match text from ../../../test-environment/other-file.lua + // + // If the plugin works, this text should show up as a suggestion. + cy.typeIntoTerminal("hip") + // the search should have been started for the prefix "hip" + cy.contains("hip").should( + "have.css", + "backgroundColor", + rgbify(flavors.macchiato.colors.flamingo.rgb), + ) + // + // blink is now in the Fuzzy(3) stage, and additional keypresses must not + // start a new ripgrep search. They must be used for filtering the + // results instead. + // https://cmp.saghen.dev/development/architecture.html#architecture + cy.contains("Hippopotamus" + "234 (rg)") // wait for blink to show up + cy.typeIntoTerminal("234") + + // wait for the highlight to disappear to test that too + cy.contains("hip").should( + "have.css", + "backgroundColor", + rgbify(flavors.macchiato.colors.base.rgb), + ) + + nvim + .runLuaCode({ + luaCode: `return _G.blink_ripgrep_invocations`, + }) + .should((result) => { + // ripgrep should only have been invoked once + expect(result.value).to.be.an("array") + expect(result.value).to.have.length(1) + }) + }) + }) + + it("can clean up (kill) a previous search", () => { + // to save resources, the plugin should clean up a previous search when a + // new search is started. Blink should handle this internally, see + // https://github.com/mikavilpas/blink-ripgrep.nvim/issues/102 + + cy.visit("/") + cy.startNeovim({}).then((nvim) => { + // wait until text on the start screen is visible + cy.contains("If you see this text, Neovim is ready!") + createGitReposToLimitSearchScope() + + // clear the current line and enter insert mode + cy.typeIntoTerminal("cc") + + // debug mode should be on by default for all tests. Otherwise it doesn't + // make sense to test this, as nothing will be displayed. + nvim.runLuaCode({ + luaCode: `assert(require("blink-ripgrep").config.debug)`, + }) + + // search for something that does not exist. This should start a couple + // of searches + cy.typeIntoTerminal("yyyyyy", { delay: 80 }) + nvim.runExCommand({ command: "messages" }).then((result) => { + expect(result.value).to.contain( + "killed previous RipgrepBackend invocation", + ) + }) + nvim + .runLuaCode({ + luaCode: `return _G.blink_ripgrep_invocations`, + }) + .should((result) => { + expect(result.value).to.be.an("array") + expect(result.value).to.have.length.above(3) + }) + }) + }) +}) diff --git a/integration-tests/cypress/e2e/blink-ripgrep/createFakeGitDirectoriesToLimitRipgrepScope.ts b/integration-tests/cypress/e2e/blink-ripgrep/createFakeGitDirectoriesToLimitRipgrepScope.ts new file mode 100644 index 0000000..54fdc77 --- /dev/null +++ b/integration-tests/cypress/e2e/blink-ripgrep/createFakeGitDirectoriesToLimitRipgrepScope.ts @@ -0,0 +1,11 @@ +// this works for both GitGrepBackend and RipgrepBackend +export function createGitReposToLimitSearchScope(): void { + cy.nvim_runBlockingShellCommand({ + command: "git init && git add . && git commit -m 'initial commit'", + cwdRelative: ".", + }) + cy.nvim_runBlockingShellCommand({ + command: "git init && git add . && git commit -m 'initial commit'", + cwdRelative: "limited", + }) +} diff --git a/integration-tests/cypress/e2e/blink-ripgrep/debug-mode.cy.ts b/integration-tests/cypress/e2e/blink-ripgrep/debug-mode.cy.ts index 2313d8c..e1ecd5c 100644 --- a/integration-tests/cypress/e2e/blink-ripgrep/debug-mode.cy.ts +++ b/integration-tests/cypress/e2e/blink-ripgrep/debug-mode.cy.ts @@ -1,6 +1,7 @@ import { flavors } from "@catppuccin/palette" import { rgbify } from "@tui-sandbox/library/dist/src/client/color-utilities" import { createGitReposToLimitSearchScope } from "./createGitReposToLimitSearchScope" +import { verifyGitGrepBackendWasUsedInTest } from "./verifyGitGrepBackendWasUsedInTest" describe("debug mode", () => { it("can execute the debug command in a shell", () => { @@ -131,7 +132,9 @@ describe("debug mode", () => { // of searches cy.typeIntoTerminal("yyyyyy", { delay: 80 }) nvim.runExCommand({ command: "messages" }).then((result) => { - expect(result.value).to.contain("killed previous invocation") + expect(result.value).to.contain( + "killed previous RipgrepBackend invocation", + ) }) nvim .runLuaCode({ @@ -143,4 +146,47 @@ describe("debug mode", () => { }) }) }) + + it("can clean up (kill) a previous git grep search", () => { + // to save resources, the plugin should clean up a previous search when a + // new search is started. Blink should handle this internally, see + // https://github.com/mikavilpas/blink-ripgrep.nvim/issues/102 + + cy.visit("/") + cy.startNeovim({ + startupScriptModifications: ["use_gitgrep_backend.lua"], + }).then((nvim) => { + // wait until text on the start screen is visible + cy.contains("If you see this text, Neovim is ready!") + createGitReposToLimitSearchScope() + + // clear the current line and enter insert mode + cy.typeIntoTerminal("cc") + + // debug mode should be on by default for all tests. Otherwise it doesn't + // make sense to test this, as nothing will be displayed. + nvim.runLuaCode({ + luaCode: `assert(require("blink-ripgrep").config.debug)`, + }) + + // search for something that does not exist. This should start a couple + // of searches + cy.typeIntoTerminal("yyyyyy", { delay: 80 }) + nvim.runExCommand({ command: "messages" }).then((result) => { + expect(result.value).to.contain( + "killed previous GitGrepBackend invocation", + ) + }) + nvim + .runLuaCode({ + luaCode: `return _G.blink_ripgrep_invocations`, + }) + .should((result) => { + expect(result.value).to.be.an("array") + expect(result.value).to.have.length.above(3) + }) + + verifyGitGrepBackendWasUsedInTest() + }) + }) }) diff --git a/integration-tests/cypress/e2e/blink-ripgrep/verifyGitGrepBackendWasUsedInTest.ts b/integration-tests/cypress/e2e/blink-ripgrep/verifyGitGrepBackendWasUsedInTest.ts new file mode 100644 index 0000000..d1109fc --- /dev/null +++ b/integration-tests/cypress/e2e/blink-ripgrep/verifyGitGrepBackendWasUsedInTest.ts @@ -0,0 +1,15 @@ +import z from "zod" + +export function verifyGitGrepBackendWasUsedInTest(): void { + cy.nvim_runLuaCode({ + luaCode: `return require("blink-ripgrep").config`, + }).then((result) => { + assert(result.value) + const config = z + .object({ future_features: z.object({ backend: z.string() }) }) + .safeParse(result.value) + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + expect(config.error).to.be.undefined + expect(config.data?.future_features.backend).to.equal("gitgrep") + }) +} diff --git a/integration-tests/test-environment/config-modifications/use_gitgrep_backend.lua b/integration-tests/test-environment/config-modifications/use_gitgrep_backend.lua new file mode 100644 index 0000000..a5090be --- /dev/null +++ b/integration-tests/test-environment/config-modifications/use_gitgrep_backend.lua @@ -0,0 +1,5 @@ +require("blink-ripgrep").setup({ + future_features = { + backend = "gitgrep", + }, +}) diff --git a/lua/blink-ripgrep/backends/git_grep/git_grep.lua b/lua/blink-ripgrep/backends/git_grep/git_grep.lua new file mode 100644 index 0000000..dae5e75 --- /dev/null +++ b/lua/blink-ripgrep/backends/git_grep/git_grep.lua @@ -0,0 +1,104 @@ +---@module "blink.cmp" + +---@class blink-ripgrep.GitGrepBackend : blink-ripgrep.Backend +local GitGrepBackend = {} + +---@param config table +function GitGrepBackend.new(config) + local self = setmetatable({}, { __index = GitGrepBackend }) + self.config = config + return self --[[@as blink-ripgrep.GitGrepBackend]] +end + +function GitGrepBackend:get_matches(prefix, context, resolve) + local command_module = + require("blink-ripgrep.backends.git_grep.gitgrep_command") + + local cwd = assert(vim.uv.cwd()) + local cmd = command_module.get_command(prefix) + + if cmd == nil then + if self.config.debug then + local debug = require("blink-ripgrep.debug") + debug.add_debug_message("no command returned, skipping the search") + debug.add_debug_invocation({ "ignored-because-no-command" }) + end + + resolve() + return + end + + if self.config.debug then + if cmd.debugify_for_shell then + cmd:debugify_for_shell() + end + + require("blink-ripgrep.visualization").flash_search_prefix(prefix) + require("blink-ripgrep.debug").add_debug_invocation(cmd) + end + + local gitgrep = vim.system(cmd.command, nil, function(result) + vim.schedule(function() + if result.code ~= 0 then + resolve() + return + end + + local lines = vim.split(result.stdout, "\n") + local parser = require("blink-ripgrep.backends.git_grep.git_grep_parser") + local output = parser.parse_output(lines, cwd) + + ---@type table + local items = {} + for _, file in pairs(output.files) do + for _, match in pairs(file.matches) do + local draw_docs = function(draw_opts) + require("blink-ripgrep.documentation").render_item_documentation( + self.config, + draw_opts, + file, + match + ) + end + + ---@diagnostic disable-next-line: missing-fields + items[match.match.text] = { + documentation = { + kind = "markdown", + draw = draw_docs, + -- legacy, will be removed in a future release of blink + -- https://github.com/Saghen/blink.cmp/issues/1113 + render = draw_docs, + }, + source_id = "blink-ripgrep", + kind = 1, + label = match.match.text, + insertText = match.match.text, + } + end + end + + -- Had some issues with E550, might be fixed upstream nowadays. See + -- https://github.com/mikavilpas/blink-ripgrep.nvim/issues/53 + vim.schedule(function() + resolve({ + is_incomplete_forward = false, + is_incomplete_backward = false, + items = vim.tbl_values(items), + context = context, + }) + end) + end) + end) + + return function() + gitgrep:kill(9) + if self.config.debug then + require("blink-ripgrep.debug").add_debug_message( + "killed previous GitGrepBackend invocation" + ) + end + end +end + +return GitGrepBackend diff --git a/lua/blink-ripgrep/backends/git_grep/git_grep_parser.lua b/lua/blink-ripgrep/backends/git_grep/git_grep_parser.lua new file mode 100644 index 0000000..f248b74 --- /dev/null +++ b/lua/blink-ripgrep/backends/git_grep/git_grep_parser.lua @@ -0,0 +1,67 @@ +local GitGrepParser = {} + +-- TODO these types are just copies of the Ripgrep types, they should be shared + +---@class(exact) blink-ripgrep.GitgrepOutput +---@field files table + +---@class(exact) blink-ripgrep.GitgrepFile +---@field language string the treesitter language of the file, used to determine what grammar to highlight the preview with +---@field matches table +---@field relative_to_cwd string the relative path of the file to the current working directory + +---@param lines string[] +---@param cwd string +function GitGrepParser.parse_output(lines, cwd) + ---@type blink-ripgrep.GitgrepOutput + local output = { files = {} } + + for _, line in ipairs(lines) do + -- selene: allow(empty_if) + if line == "" or not line then + -- ignore + else + -- example line: + -- other-file.lua\\0003\\00014\\0Hippopotamus234 + local parts = vim.split(line, "\0") + assert(#parts == 4, "Unexpected parts in line: " .. line) + local filename = parts[1] + local line_number = assert(tonumber(parts[2])) + local start_col = assert(tonumber(parts[3])) - 1 + local text = parts[4] + + local relative_filename = filename + if filename:sub(1, #cwd) == cwd then + relative_filename = filename:sub(#cwd + 2) + end + + local file = output.files[relative_filename] + if not file then + local ft = vim.filetype.match({ filename = filename }) + local ext = vim.fn.fnamemodify(filename, ":e") + local language = ft + or vim.treesitter.language.get_lang(ext or "text") + or ext + + file = { + language = language, + matches = {}, + relative_to_cwd = relative_filename, + } + output.files[relative_filename] = file + end + if not file.matches[text] then + file.matches[text] = { + match = { text = text }, + start_col = start_col, + end_col = start_col + #text, + line_number = line_number, + } + end + end + end + + return output +end + +return GitGrepParser diff --git a/lua/blink-ripgrep/backends/git_grep/gitgrep_command.lua b/lua/blink-ripgrep/backends/git_grep/gitgrep_command.lua new file mode 100644 index 0000000..a515219 --- /dev/null +++ b/lua/blink-ripgrep/backends/git_grep/gitgrep_command.lua @@ -0,0 +1,55 @@ +---@class blink-ripgrep.GitgrepCommand +---@field command string[] +local GitgrepCommand = {} +GitgrepCommand.__index = GitgrepCommand + +--- Constructor. +---@param prefix string +---@return blink-ripgrep.GitgrepCommand | nil, string? # The command to run, or an error message. +---@nodiscard +function GitgrepCommand.get_command(prefix) + local cmd = { + "git", + "grep", + "--only-matching", + "--ignore-case", + "--perl-regexp", + "--line-number", + "--column", + -- ignore binary files + "-I", + "--word-regexp", + -- use a null byte as the separator. This avoids issues with whitespace + -- being padded in the line and column number output. + "--null", + "--", + prefix .. "[\\w_-]+", + } + + -- TODO support additional options to git grep + + local command = setmetatable({ + command = cmd, + }, GitgrepCommand) + + return command +end + +-- Print the command to :messages for debugging purposes. +function GitgrepCommand:debugify_for_shell() + -- print the command to :messages for hacky debugging, but don't show it + -- in the ui so that it doesn't interrupt the user's work + local debug_cmd = vim.deepcopy(self.command) + assert(#debug_cmd == 12, "unexpected command length") + + -- The pattern is not compatible with shell syntax, so escape it + -- separately. The user should be able to copy paste it into their posix + -- compatible terminal. + local pattern = debug_cmd[12] + debug_cmd[12] = "'" .. pattern .. "'" + + local things = table.concat(debug_cmd, " ") + vim.api.nvim_exec2("echomsg " .. vim.fn.string(things), {}) +end + +return GitgrepCommand diff --git a/lua/blink-ripgrep/backends/ripgrep/ripgrep.lua b/lua/blink-ripgrep/backends/ripgrep/ripgrep.lua index a973378..2aa1cd7 100644 --- a/lua/blink-ripgrep/backends/ripgrep/ripgrep.lua +++ b/lua/blink-ripgrep/backends/ripgrep/ripgrep.lua @@ -1,11 +1,11 @@ ----@class blink-ripgrep.Backend +---@class blink-ripgrep.RipgrepBackend : blink-ripgrep.Backend local RipgrepBackend = {} ---@param config table function RipgrepBackend.new(config) local self = setmetatable({}, { __index = RipgrepBackend }) self.config = config - return self --[[@as blink-ripgrep.Backend]] + return self --[[@as blink-ripgrep.RipgrepBackend]] end function RipgrepBackend:get_matches(prefix, context, resolve) @@ -66,13 +66,11 @@ function RipgrepBackend:get_matches(prefix, context, resolve) local items = {} for _, file in pairs(parsed.files) do for _, match in pairs(file.matches) do - local matchkey = match.match.text + local match_text = match.match.text -- PERF: only register the match once - right now there is no useful -- way to display the same match multiple times - if not items[matchkey] then - local label = match.match.text - + if not items[match_text] then local draw_docs = function(draw_opts) require("blink-ripgrep.documentation").render_item_documentation( self.config, @@ -83,7 +81,7 @@ function RipgrepBackend:get_matches(prefix, context, resolve) end ---@diagnostic disable-next-line: missing-fields - items[matchkey] = { + items[match_text] = { documentation = { kind = "markdown", draw = draw_docs, @@ -93,8 +91,8 @@ function RipgrepBackend:get_matches(prefix, context, resolve) }, source_id = "blink-ripgrep", kind = kinds.Text, - label = label, - insertText = matchkey, + label = match_text, + insertText = match_text, } end end @@ -117,7 +115,7 @@ function RipgrepBackend:get_matches(prefix, context, resolve) rg:kill(9) if self.config.debug then require("blink-ripgrep.debug").add_debug_message( - "killed previous invocation" + "killed previous RipgrepBackend invocation" ) end end diff --git a/lua/blink-ripgrep/backends/ripgrep/ripgrep_command.lua b/lua/blink-ripgrep/backends/ripgrep/ripgrep_command.lua index f821639..a068de6 100644 --- a/lua/blink-ripgrep/backends/ripgrep/ripgrep_command.lua +++ b/lua/blink-ripgrep/backends/ripgrep/ripgrep_command.lua @@ -2,7 +2,6 @@ ---@field command string[] ---@field root string ---@field additional_roots string[] ----@field debugify_for_shell? fun(self):nil # Echo the command to the messages buffer for debugging purposes. local RipgrepCommand = {} RipgrepCommand.__index = RipgrepCommand @@ -55,12 +54,16 @@ function RipgrepCommand:debugify_for_shell() -- print the command to :messages for hacky debugging, but don't show it -- in the ui so that it doesn't interrupt the user's work local debug_cmd = vim.deepcopy(self.command) + assert(#debug_cmd >= 10) -- The pattern is not compatible with shell syntax, so escape it -- separately. The user should be able to copy paste it into their posix -- compatible terminal. local pattern = debug_cmd[9] + assert(pattern) debug_cmd[9] = "'" .. pattern .. "'" + + assert(debug_cmd[10]) debug_cmd[10] = vim.fn.fnameescape(debug_cmd[10]) local things = table.concat(debug_cmd, " ") diff --git a/lua/blink-ripgrep/backends/ripgrep/ripgrep_parser.lua b/lua/blink-ripgrep/backends/ripgrep/ripgrep_parser.lua index 8b8d499..925a084 100644 --- a/lua/blink-ripgrep/backends/ripgrep/ripgrep_parser.lua +++ b/lua/blink-ripgrep/backends/ripgrep/ripgrep_parser.lua @@ -5,10 +5,10 @@ local M = {} ---@class blink-ripgrep.RipgrepFile ---@field language string the treesitter language of the file, used to determine what grammar to highlight the preview with ----@field matches table +---@field matches table ---@field relative_to_cwd string the relative path of the file to the current working directory ----@class blink-ripgrep.RipgrepMatch +---@class blink-ripgrep.Match ---@field line_number number ---@field start_col number ---@field end_col number @@ -49,9 +49,8 @@ function M.parse(ripgrep_output, cwd) relative_filename = filename:sub(#cwd + 2) end - local ext = vim.fn.fnamemodify(filename, ":e") - local ft = vim.filetype.match({ filename = filename }) + local ext = vim.fn.fnamemodify(filename, ":e") local language = ft or vim.treesitter.language.get_lang(ext or "text") or ext diff --git a/lua/blink-ripgrep/documentation.lua b/lua/blink-ripgrep/documentation.lua index 2535c07..904e37c 100644 --- a/lua/blink-ripgrep/documentation.lua +++ b/lua/blink-ripgrep/documentation.lua @@ -1,3 +1,5 @@ +---@module "blink-ripgrep.backends.git_grep.git_grep_parser" + local documentation = {} local highlight_ns_id = 0 @@ -10,8 +12,8 @@ vim.api.nvim_set_hl(0, "BlinkRipgrepMatch", { link = "Search", default = true }) ---@param config blink-ripgrep.Options ---@param draw_opts blink.cmp.CompletionDocumentationDrawOpts ----@param file blink-ripgrep.RipgrepFile ----@param match blink-ripgrep.RipgrepMatch +---@param file blink-ripgrep.RipgrepFile | blink-ripgrep.GitgrepFile +---@param match blink-ripgrep.Match | blink-ripgrep.Match function documentation.render_item_documentation(config, draw_opts, file, match) local bufnr = draw_opts.window:get_buf() ---@type string[] diff --git a/lua/blink-ripgrep/highlighting.lua b/lua/blink-ripgrep/highlighting.lua index 5bc28a0..713ca9a 100644 --- a/lua/blink-ripgrep/highlighting.lua +++ b/lua/blink-ripgrep/highlighting.lua @@ -4,7 +4,7 @@ local M = {} --- shown, highlight the match so that the user can easily see where the match --- is. ---@param bufnr number ----@param match blink-ripgrep.RipgrepMatch +---@param match blink-ripgrep.Match ---@param highlight_ns_id number ---@param context_preview blink-ripgrep.NumberedLine[] function M.highlight_match_in_doc_window( diff --git a/lua/blink-ripgrep/init.lua b/lua/blink-ripgrep/init.lua index e53390c..b889ef4 100644 --- a/lua/blink-ripgrep/init.lua +++ b/lua/blink-ripgrep/init.lua @@ -19,6 +19,11 @@ ---@class blink-ripgrep.FutureFeatures ---@field toggles? blink-ripgrep.ToggleKeymaps # Keymaps to toggle features on/off. This can be used to alter the behavior of the plugin without restarting Neovim. Nothing is enabled by default. +---@field backend? blink-ripgrep.BackendSelection # The backend to use for searching. Defaults to "ripgrep". "gitgrep" is available as a preview right now. + +---@alias blink-ripgrep.BackendSelection +---| "ripgrep" # Use ripgrep (rg) for searching. Works in most cases. +---| "gitgrep" # Use git grep for searching. This is faster but only works in git repositories. ---@class blink-ripgrep.ToggleKeymaps ---@field on_off? string # The keymap to toggle the plugin on and off from blink completion results. Example: "tg" @@ -51,7 +56,10 @@ RgSource.config = { project_root_fallback = true, additional_paths = {}, mode = "on", - future_features = { toggles = nil }, + future_features = { + toggles = nil, + backend = "ripgrep", + }, } -- set up default options so that they are used by the next search @@ -97,8 +105,22 @@ function RgSource:get_completions(context, resolve) return end - local ripgrep = - require("blink-ripgrep.backends.ripgrep.ripgrep").new(RgSource.config) + ---@type blink-ripgrep.Backend | nil + local backend + do + local be = (self.config.future_features or {}).backend + + if be == "gitgrep" then + backend = + require("blink-ripgrep.backends.git_grep.git_grep").new(RgSource.config) + elseif be == "ripgrep" then + backend = + require("blink-ripgrep.backends.ripgrep.ripgrep").new(RgSource.config) + end + + assert(backend, "Invalid backend " .. vim.inspect(be)) + end + local prefix = self.get_prefix(context) if string.len(prefix) < self.config.prefix_min_len then @@ -106,7 +128,7 @@ function RgSource:get_completions(context, resolve) return end - local cancellation_function = ripgrep:get_matches(prefix, context, resolve) + local cancellation_function = backend:get_matches(prefix, context, resolve) return cancellation_function end