L3MON4D3 / LuaSnip

Snippet Engine for Neovim written in Lua.
Apache License 2.0
3.37k stars 240 forks source link

Understand callbacks #1219

Closed banana59 closed 1 month ago

banana59 commented 2 months ago

Hello, I am currently working on a callback function which should be executed after a snippet is expanded.

This is my function

local function check_import_work_in_progress(args, _, user_args)
    if type(user_args) ~= "string" then
        error("Invalid check_import input")
    end

    local lines = vim.api.nvim_buf_get_lines(0, 0, -1, false)
    local import_exists = false

    for _, line in ipairs(lines) do
        if line:match(user_args) then
            import_exists = true
            break  -- if found don't import anything
        end
    end

    if not import_exists then
        -- Prepend the import statement to the lines
        table.insert(lines, 1, user_args)
        vim.api.nvim_buf_set_lines(0, 0, -1, false, lines)
    end

    -- Move the cursor to the end of the buffer
    local total_lines = #lines
    local last_line_length = #lines[total_lines] or 0
    vim.api.nvim_win_set_cursor(0, {total_lines, last_line_length})
end

This in my snippet in python.lua

s({trig="plot_2d", snippetType="snippet"},
    fmta(
        [[
            fig, ax = plt.subplots(figsize=[12, 10])
            ax.plt(<>, <>)
            ax.grid()
            ax.set_xlabel("<>")
            ax.set_ylabel("<>")

            plt.show()
            <>
        ]],
        {
            i(1), i(2),
            i(3, "x-Axis"),
            i(4, "y-Axis"),
            i(0)
        }
    ),
    {
        callbacks = {
            [-1] = {
                [events.post_expand] = check_import("import matplotlib.pyplot as plt")
            }
        }
    }
),

But the function input doesn't seem to be accepted. Can anyone help me with that?

L3MON4D3 commented 1 month ago

Hi :)

One thing that will seriously inhibit luasnip is the current way check_import_work_in_progress inserts text, replacing the entire buffer via nvim_buf_set_lines will invalidate all extmarks set by luasnip for tracking nodes, and thus all snippets (to fix, insert only the new line into the buffer)

To make sure that's not the current issue, could you try something simple, like [events.post_expand] = function() print("foo") end?

banana59 commented 1 month ago

Thanks, that's useful to know. I have replaced the nvim_buf_set_lines range to only affect the first line. Unfortunately I can't find an example on how the correct syntax is for this. Do you have any more documentation than the DOC.md and Examples/snippets.lua? With your minimal example function() ... end I get this error:

Error detected while processing BufWritePost Autocommands for "*":
Error executing lua callback: ...share/nvim/lazy/LuaSnip/lua/luasnip/loaders/from_lua.lua:169: Failed to execute .../LuaSnip/python.lua
: .../default/LuaSnip/python.lua:120: table index is nil
stack traceback:
        [C]: in function 'error'
        ...share/nvim/lazy/LuaSnip/lua/luasnip/loaders/from_lua.lua:169: in function '_luasnip_load_file'
        ...share/nvim/lazy/LuaSnip/lua/luasnip/loaders/from_lua.lua:285: in function 'load_file'
        ...share/nvim/lazy/LuaSnip/lua/luasnip/loaders/from_lua.lua:354: in function 'reload'
        ...share/nvim/lazy/LuaSnip/lua/luasnip/loaders/from_lua.lua:241: in function 'change_file'
        ...re/nvim/lazy/LuaSnip/lua/luasnip/loaders/fs_watchers.lua:366: in function 'change_file'
        ...re/nvim/lazy/LuaSnip/lua/luasnip/loaders/fs_watchers.lua:223: in function 'BufWritePost_callback'
        ...re/nvim/lazy/LuaSnip/lua/luasnip/loaders/fs_watchers.lua:97: in function <...re/nvim/lazy/LuaSnip/lua/luasnip/loaders/fs_watchers.lua:66>
L3MON4D3 commented 1 month ago

Oh MB, there actually isn't a post_expand-event, should've spotted that earlier :sweat_smile: IIUC you could do this in pre_expand, that way you don't even have to worry about setting the cursor correctly again, since it will be set by luasnip during expansion :)

banana59 commented 1 month ago

The code is accepted. I try to do this with pre_expand, but getting the lines before the snippet will result in capturing a state before the snippet exists. This will result that the import matplotlib... string is always inserted after the snippet. That's why I thought about something like a post_expand. Do you know any alternative? This is the currenet state of the function:

    -- Function to check if import statement exists and add if not
    local function check_import_work_in_progress(args)
        local user_args = "import matplotlib.pyplot as plt"
        if type(user_args) ~= "string" then
            error("Invalid check_import input")
        end

        local lines = vim.api.nvim_buf_get_lines(0, 0, -1, false)
        local import_exists = false

        for _, line in ipairs(lines) do
            if line:match(user_args) then
                import_exists = true
                break  -- if found don't import anything
            end
        end

        if not import_exists then
            -- Prepend the import statement to the lines
            table.insert(lines, 2, user_args)
            vim.api.nvim_buf_set_lines(0, 0, 0, false, lines)
        end
    end

This is the current state of the snippet

    s({trig="plot_2d", snippetType="snippet"},
        fmta(
            [[
                fig, ax = plt.subplots(figsize=[12, 10])
                ax.plot(<>, <>)
                ax.grid()
                ax.set_xlabel("<>")
                ax.set_ylabel("<>")

                plt.show()
                <>
            ]],
            {
                i(1), i(2),
                i(3, "x-Axis"),
                i(4, "y-Axis"),
                i(0)
            }
        ),
        {
            callbacks = {
                [-1] = {  -- -1 refers to the snippet as a whole
                    [events.pre_expand] = check_import_work_in_progress
                }
            }
        }
    ),

Thank you very much for your help by the way. I really appreciate your time.

L3MON4D3 commented 1 month ago

Ah, okay, I think I know what's going on (and it's a bit involved, sorry)

So, I think what's happening here is that the code inserts the import-statement right on top of the extmark, which makes it shift to the left of, aka before, the snippet. So, this only occurs if the snippet is expanded at the first column of the first line (aka if the trigger starts exactly there). I think it'd be nice to be able to handle even this case gracefully, so I'll make it so the pre_expand-callback receives the id of that extmark, so one may place it correctly after inserting the line (90333731):

ls.setup_snip_env()

-- Function to check if import statement exists and add if not
local function check_import_work_in_progress(node, args)
    local user_args_pattern = "^import matplotlib.pyplot as plt"
    local user_args = "import matplotlib.pyplot as plt"
    if type(user_args) ~= "string" then
        error("Invalid check_import input")
    end

    local lines = vim.api.nvim_buf_get_lines(0, 0, -1, false)
    local import_exists = false

    for _, line in ipairs(lines) do
        if line:match(user_args_pattern) then
            import_exists = true
            break  -- if found don't import anything
        end
    end

    if not import_exists then
        -- make linebreak with `, ""`
        vim.api.nvim_buf_set_text(0, 0,0, 0,0, {user_args, ""})
        -- check for failure-case
        if vim.deep_equal(args.expand_pos, {0,0}) then
            -- move expand-pos-extmark.
            vim.api.nvim_buf_set_extmark(0, require("luasnip.session").ns_id, 1,0, {id = args.expand_pos_mark_id})
        end
    end
end

ls.add_snippets("all", {
      s({trig="plot_2d", snippetType="snippet"},
        fmta(
            [[
                fig, ax = plt.subplots(figsize=[12, 10])
                ax.plot(<>, <>)
                ax.grid()
                ax.set_xlabel("<>")
                ax.set_ylabel("<>")

                plt.show()
                <>
            ]],
            {
                i(1), i(2),
                i(3, "x-Axis"),
                i(4, "y-Axis"),
                i(0)
            }
        ),
        {
            callbacks = {
                [-1] = {  -- -1 refers to the snippet as a whole
                    [events.pre_expand] = check_import_work_in_progress
                }
            }
        }
    ),
}, {key ="a"})

What you mentioned with the post_expand-callback sounds conceptually simpler, until one knows that the snippet also consists of extmarks, all of which may also have to be adjusted to support this (make sure that they will shift into the correct direction), so sticking this into pre_expand makes it a bit easier :)

banana59 commented 1 month ago

All right. The functionality is nearly perfect. The code implementation is working as expected. The only this here is that in case 1: import ... not there when expanding - the cursor is before the inserted import ... element and further expand_or_jump() calls for the choice nodes are not possible. I am simply in insert mode. In case 2: import ... there when expanding - the cursor is at the bottom of snippet and the snippet choice nodes are not activated / triggered.

Concluding, in both cases the other options in the snippet like choice nodes are not triggered.

L3MON4D3 commented 1 month ago

Hmm, for me everything seems to be working, have you updated to the latest version of luasnip? I added the referenced commit just yesterday :) If you're up-to-date, could you give some more details?

https://github.com/user-attachments/assets/5b13cd54-36d9-43de-9277-88f1cf557b1e

banana59 commented 1 month ago

I now have the newest commit. I used tag 2.3.0. Now it is working, awesome! May I ask you for one last help with this? I want to make this function universal by adding the string as a parameter. The result is, that the parameter is added after the snippet again. I haven't quite get my head around the mechanism, why this is happening anyway.

-- Function to check if import statement exists and add if not
local function check_import(node, args)
    if type(args[1]) ~= "string" then
        error("Invalid check_import input")
    end

    local lines = vim.api.nvim_buf_get_lines(0, 0, -1, false)
    local import_exists = false

    for _, line in ipairs(lines) do
        if line:match("^" .. args[1]) then
            import_exists = true
            break  -- if found don't import anything
        end
    end

    if not import_exists then
        -- make linebreak with `, ""`
        vim.api.nvim_buf_set_text(0, 0,0, 0,0, {args[1], ""})
        -- check for failure-case
        if vim.deep_equal(args.expand_pos, {0,0}) then
            -- move expand-pos-extmark.
            vim.api.nvim_buf_set_extmark(0, require("luasnip.session").ns_id, 1,0, {id = args.expand_pos_mark_id})
        end
    end
end

return {
      s({trig="plot_2d", snippetType="snippet"},
        fmta(
            [[
                fig, ax = plt.subplots(figsize=[12, 10])
                ax.plot(<>, <>)
                ax.grid()
                ax.set_xlabel("<>")
                ax.set_ylabel("<>")

                plt.show()
                <>
            ]],
            {
                i(1), i(2),
                i(3, "x-Axis"),
                i(4, "y-Axis"),
                i(0)
            }
        ),
        {
            callbacks = {
                [-1] = {  -- -1 refers to the snippet as a whole
                    [events.pre_expand] = function(node, args)
                        check_import(node, {"import matplotlib.pyplot as plt"})
                    end
                 }
            }
        }
    ),
}
L3MON4D3 commented 1 month ago

Ha, nice :) That's almost correct, just make sure to pass the second parameter, args through to check_import, so check_import(node, args, user_args) (or omit the node altogether, I don't think it's necessary)

banana59 commented 1 month ago

I am sorry but I don't seem to get it. Thanks for your patience. With this code I still insert the text after the snippet.

-- Function to check if import statement exists and add if not
local function check_import(args, user_args)
    if type(user_args[1]) ~= "string" then
        error("Invalid check_import input")
    end

    local lines = vim.api.nvim_buf_get_lines(0, 0, -1, false)
    local import_exists = false

    for _, line in ipairs(lines) do
        if line:match("^" .. user_args[1]) then
            import_exists = true
            break  -- if found don't import anything
        end
    end

    if not import_exists then
        -- make linebreak with `, ""`
        vim.api.nvim_buf_set_text(0, 0,0, 0,0, {user_args[1], ""})
        -- check for failure-case
        if vim.deep_equal(args.expand_pos, {0,0}) then
            -- move expand-pos-extmark.
            vim.api.nvim_buf_set_extmark(0, require("luasnip.session").ns_id, 1,0, {id = args.expand_pos_mark_id})
        end
    end
end

return {
    s({trig="plot_2d", snippetType="snippet"},
        fmta(
            [[
                fig, ax = plt.subplots(figsize=[12, 10])
                ax.plot(<>, <>)
                ax.grid()
                ax.set_xlabel("<>")
                ax.set_ylabel("<>")

                plt.show()
                <>
            ]],
            {
                i(1), i(2),
                i(3, "x-Axis"),
                i(4, "y-Axis"),
                i(0)
            }
        ),
        {
            callbacks = {
                [-1] = {  -- -1 refers to the snippet as a whole
                    [events.pre_expand] = function(args)
                        check_import(args, {"import matplotlib.pyplot as plt"})
                    end
                 }
            }
        }
    ),
}
L3MON4D3 commented 1 month ago

No worries: the callback, so the function you pass to events.pre_expand receives two arguments, first node (the node that triggered the event), and second args, which has some additional data (check_import needs these args because they contain args.expand_pos and args.expand_pos_mark_id) So what you have right now almost works correctly, only the argument args in the event-callback receives the node. If you instead write [events.pre_expand] = function(_, args) it should work correctly (_ is usually used for unused arguments) Hope that fixes it :)

banana59 commented 1 month ago

That's it! Thank you L3MON4D3.

So in case of iterating over the table inputted check_import(args, {"from mpl_toolkits.mplot3d import Axes3D", "import numpy as np"}) I somehow have to make sure that the args parameter gets updated sequentially somehow. How do I do that?

-- Function to check if import statement exists and add if not add it to the top
local function check_import(args, user_args)
    if type(user_args) ~= "table" then
        error("Invalid check_import input")
    end

    for _, import_statement in ipairs(user_args) do
        local lines = vim.api.nvim_buf_get_lines(0, 0, -1, false)

        if type(import_statement) ~= "string" then
            error("Each element in user_args should be a string")
        end

        local import_exists = false

        for _, line in ipairs(lines) do
            if line:match("^" .. import_statement) then
                import_exists = true
                break  -- if found don't import anything
            end
        end

        if not import_exists then
            vim.api.nvim_buf_set_text(0, 0,0, 0,0, {import_statement, ""})  -- make linebreak with `, ""`

            if vim.deep_equal(args.expand_pos, {0,0}) then  -- check for failure-case
                vim.api.nvim_buf_set_extmark(0, require("luasnip.session").ns_id, 1,0, {id = args.expand_pos_mark_id})  -- move expand-pos-extmark.
            end
        end
    end
end
L3MON4D3 commented 1 month ago

I think I'd

banana59 commented 1 month ago

For the sake of completeness, this is the code checking for multiple imports / includes

-- Function to check if import statement exists and add if not add it to the top
local function check_import(args, user_args)
    if type(user_args) ~= "table" then
        error("Invalid check_import input")
    end

    local lines = vim.api.nvim_buf_get_lines(0, 0, -1, false)
    local inserted_lines = 0

    for _, import_statement in ipairs(user_args) do
        if type(import_statement) ~= "string" then
            error("Each element in user_args should be a string")
        end

        local import_exists = false

        for _, line in ipairs(lines) do
            if line:match("^" .. import_statement) then
                import_exists = true
                break  -- don't import anything
            end
        end

        if not import_exists then
            vim.api.nvim_buf_set_text(0, 0, 0, 0, 0, {import_statement, ""})  -- make linebreak with `, ""`
            inserted_lines = inserted_lines + 1
        end
    end

    if vim.deep_equal(args.expand_pos, {0, 0}) then  -- check for failure-case
        vim.api.nvim_buf_set_extmark(0, require("luasnip.session").ns_id, inserted_lines, 0, {id = args.expand_pos_mark_id})  -- move expand-pos-extmark.
    end
end

and this a corresponding snippet example

return {
    s({trig="plot_3d_surface", snippetType="snippet"},
        fmta(
            [[
                # Create data
                x = np.linspace(<>, <>, 100)
                y = np.linspace(<>, <>, 100)
                X, Y = np.meshgrid(x, y)
                Z = <>

                # Plot
                fig = plt.figure()
                ax = fig.add_subplot(111, projection='3d')

                ax.plot_surface(X, Y, Z, cmap='viridis')

                ax.set_xlabel('X Label')
                ax.set_ylabel('Y Label')
                ax.set_zlabel('Z Label')

                plt.show()
                <>
            ]],
            {
                i(1),
                i(2),
                i(3),
                i(4),
                i(5),
                i(0),
            }
        ),
        {
            callbacks = {
                [-1] = {  -- -1 refers to the snippet as a whole
                    [events.pre_expand] = function(_, args)
                        check_import(args, {"from mpl_toolkits.mplot3d import Axes3D", "import numpy as np"})
                    end
                 }
            }
        }
    )
}

assuming you are in ./LuaSnip/python.lua and declared above

local ls = require("luasnip")
local s = ls.snippet
local t = ls.text_node
local i = ls.insert_node
local c = ls.choice_node
local fmta = require("luasnip.extras.fmt").fmta
local events = require("luasnip.util.events")