attempt.nvim icon indicating copy to clipboard operation
attempt.nvim copied to clipboard

feat: run an attempt based on visual selection

Open Skyppex opened this issue 9 months ago • 4 comments

having enjoyed attempt.nvim for quite a while i wanted to upgrade my workflow a little. i ended up writing some code using attempt.nvim's api to run a visual selection of text as code in a selected language. now i'm enjoying attempt.nvim even more because this was deceptively simple to implement, but still had a few caveats to look out for.

the feature request is to add this to the plugin itself for others to enjoy. making this issue to see if its within scope for attempt.nvim. i could probably make a PR of this implementation but it might be smart to reconsider how to do this exactly.

the basic idea of what i'm doing here is:

  • get the currently highlighted text
  • create a new attempt buffer and put that text into that buffer
  • run the attempt based on the normal attempt config
  • delete the buffer after running (by delaying for 3 seconds)

this is a dump of my implementation. keep in mind its just code which makes it work for me and what i consider to be good enough. there are a few hitches to consider and i'm willing to elaborate in the comments if needed.

local attempt = require("attempt")

local function get_visual_selection(start_pos, end_pos)
	local lines = vim.fn.getline(start_pos[2], end_pos[2])

	if not lines then
		return {}
	end

	-- Adjust for partial lines
	lines[#lines] = string.sub(lines[#lines], 1, end_pos[3])
	lines[1] = string.sub(lines[1], start_pos[3], -1)

	return lines
end

local function trim_left_based_on_first_line(lines)
	local first_line = lines[1]
	local leading_whitespace = first_line:match("^(%s*)")
	local trim_len = #leading_whitespace

	local trimmed = {}
	for _, line in ipairs(lines) do
		local trimmed_line = line
		if #line >= trim_len then
			trimmed_line = line:sub(trim_len + 1)
		end
		table.insert(trimmed, trimmed_line)
	end

	return trimmed
end

local attempt_config = require("attempt.config").opts

local function run_inline_attempt()
	if not attempt_config then
		attempt_config = require("attempt.config").opts

		if not attempt_config then
			vim.notify("Attempt configuration not found", vim.log.levels.ERROR)
			return
		end
	end

	local current_buf = vim.api.nvim_get_current_buf()
	vim.cmd("normal! gv")
	local start_pos = vim.fn.getpos("'<")
	local end_pos = vim.fn.getpos("'>")

	if start_pos[2] == 0 or end_pos[2] == 0 then
		vim.notify("No visual selection found", vim.log.levels.WARN)
		return
	end

	local lines = get_visual_selection(start_pos, end_pos)

	if #lines == 0 then
		vim.notify("No lines selected", vim.log.levels.WARN)
		return
	end

	lines = trim_left_based_on_first_line(lines)

	local options = attempt_config.ext_options

	vim.ui.select(options, {}, function(choice)
		if not choice then
			return
		end

		local autoformat_disabled = vim.g.disable_autoformat or false
		vim.g.disable_autoformat = true
		attempt.new({
			ext = choice,
		}, function(file_entry)
			local path = file_entry.path
			local bufnr = vim.fn.bufnr(path, false)

			vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines)
			vim.api.nvim_buf_call(bufnr, function()
				vim.cmd("write")
			end)

			attempt.run(bufnr)
			vim.api.nvim_set_current_buf(current_buf)

            -- some commands takes longer to execute so we can't delete the file immediately
			vim.defer_fn(function()
				attempt.delete_buf(true, bufnr)
			end, 3000)

			vim.g.disable_autoformat = autoformat_disabled
		end)
	end)
end

vim.keymap.set("x", "<leader>AR", run_inline_attempt, { desc = "Run Selection as Attempt" })

Skyppex avatar Jul 07 '25 14:07 Skyppex

I'm glad you're enjoying the plugin!

I really like the idea. I'm not sure I'd include all of this in the plugin (feels a bit too custom, and this plugin to me is more about providing a basic API that people can adapt to their workflow).

However, I think it'd be great to add some function that makes this usecase a bit more ergonomic (something like function run_string(input_string, extension, finish_callback) maybe, that handles the creation/running/deletion of the file, and maybe even a function to easily get the trimmed visual selection), and provide documentation on how to do the rest of it

This way, people could also use it to run code from other sources (clipboard maybe?) and also allows them to define their own logic for selecting the file extension (maybe vim.ui.select can be avoided in some cases)

What do you think?

m-demare avatar Jul 08 '25 13:07 m-demare

i totally agree with you, my code here was certainly more as a representation of what could be done. it is very tailored to the flow i wanted and providing a more ergonomic and flexible api here sounds like a good choice. the trimmed visual selection is something i noticed i needed when trying to V-LINE select python code and run it. if it has any indents to begin with it doesn't run properly so that trim_left_based_on_first_line was my solution to that xD.

now this one: run_string(input_string, extension, finish_callback) is interesting because here you pass in the extension which i'm not sure happens on any of the other API, i can see why you'd want that though and if this is absolutely what you want then adding a function for select_extension might be a nice utility function to throw in so people don't have to do what i've done with extracting the keys from the config.

lets say we go for:

  • run_string(input_string, extension, finish_callback) - to run text as code.
  • optionally select_extension() - to, well, select the extension from ext_options.
    • do you prefer it to return the extension or use a callback?
  • trim_input_string(input_string) - to fix stuff like i mentioned for python code or other significant whitespace languages.
    • this way you can also transform code in registers, clipboard, selection, or any other sources.
    • should this be left up to the user?
    • should this be run automatically in the run_string(...) function instead? no need for the user to call it themselves.

with this there is no getting the visual selection in the api, but with the addition of an example in the docs that function can be copy/pasted into their own config. no need to provide it in the api nessesarily.

if i were to rewrite my own stuff with these new apis:

local function run_inline_attempt()
	local lines = get_visual_selection()

	if lines == nil or #lines == 0 then
		vim.notify("No lines selected", vim.log.levels.WARN)
		return
	end

	-- this is where we start using the new api
	local ext = attempt.select_extension()

	-- selection might have been cancelled
	if ext == nil then
		vim.notify("No extension selected", vim.log.levels.WARN)
		return
	end

	-- turn the lines into a string
	local input_string = ""

	for line in lines do
		input_string = input_string .. line .. "\n"
	end

	-- if this isn't automatically handled by run_string
	-- input_string = attempt.trim_input_string(input_string)

	-- the existing attempt.nvim config should
	-- handle the creation of the temporary attempt buffer, running of the code,
	-- and deletion of the temporary buffer after execution is complete.
	-- preferably not by waiting 3 seconds.
	-- what should happen if no callback is provided? attempt has a default
	-- callback if i remember correctly where it just prints the output.
	attempt.run_string(input_string, ext)
end

this is much more concice for sure, is this what you are aiming for? it would be interesting to see an example for how this would look when using the clipboard as input instead as a second example we can evaluate towards. i imagine its just a difference of using a get_clipboard_string() instead of get_visual_selection and then not having to concat all the lines into a single string.

another thing to consider here is if you want the input_string to be a single string or if it should be a list of lines. neovim workes with lines almost everywhere as you probably know so it might be easier to just do that instead of transforming between the two back and forth and having to deal with different line endings and what-not.

Skyppex avatar Jul 10 '25 08:07 Skyppex

Hi! Sorry for the delay, I've been super busy as of late

the trimmed visual selection is something i noticed i needed when trying to V-LINE select python code and run it. if it has any indents to begin with it doesn't run properly so that trim_left_based_on_first_line was my solution to that xD.

Yeah hahah I figured that was the reason why. I don't mind including it in the plugin, but it could also be left to the user, no strong feelings regarding that

now this one: run_string(input_string, extension, finish_callback) is interesting because here you pass in the extension which i'm not sure happens on any of the other API, i can see why you'd want that though and if this is absolutely what you want then adding a function for select_extension might be a nice utility function to throw in so people don't have to do what i've done with extracting the keys from the config.

It's only done in :h attempt.new(). I think it could be optional, and if it's not provided it takes the extension from the current file. However, an option would definetly would be nice, because you don't always want to use the same file extension as the source file (think if you have some JS inside an HTML file, you want to run that with the JS runner). Providing a picker using vim.ui.select() sounds good to me

lets say we go for:

  • run_string(input_string, extension, finish_callback) - to run text as code.
  • optionally select_extension() - to, well, select the extension from ext_options.
    • do you prefer it to return the extension or use a callback?
  • trim_input_string(input_string) - to fix stuff like i mentioned for python code or other significant whitespace languages.
    • this way you can also transform code in registers, clipboard, selection, or any other sources.
    • should this be left up to the user?
    • should this be run automatically in the run_string(...) function instead? no need for the user to call it themselves.

select_extension() will have to take a callback, since vim.ui.select() is async. Regarding trim_input_string, I think I'd leave it to the user just in case? I don't think it's to likely to bother anyone really if it's done automatically inside run_string either, as you prefer really

this is much more concice for sure, is this what you are aiming for?

Yes!

another thing to consider here is if you want the input_string to be a single string or if it should be a list of lines. neovim workes with lines almost everywhere as you probably know so it might be easier to just do that instead of transforming between the two back and forth and having to deal with different line endings and what-not.

Yeah, I guess it makes sense to try to keep the APIs as similar to nvim's as posible

m-demare avatar Jul 21 '25 13:07 m-demare

thanks for getting back to me, i think most question are answered at this point. I'll take a stab at a PR soon :)

Skyppex avatar Jul 21 '25 14:07 Skyppex