diff --git a/DOC.md b/DOC.md index d83431b46..0e78fb649 100644 --- a/DOC.md +++ b/DOC.md @@ -124,6 +124,11 @@ only apply to some nodes (`user_args` for both function and dynamicNode). These `opts` are only mentioned if they accept options that are not common to all nodes. +## Node-Api: + +- `get_jump_index()`: this method returns the jump-index of a node. If a node + doesn't have a jump-index, this method returns `nil` instead. + # SNIPPETS The most direct way to define snippets is `s`: @@ -237,7 +242,7 @@ which is passed to the function. (in most cases `parent == parent.snippet`, but the `parent` of the dynamicNode is not always the surrounding snippet, it could be a `snippetNode`). -## Api: +## Snippet-Api: - `invalidate()`: call this method to effectively remove the snippet. The snippet will no longer be able to expand via `expand` or `expand_auto`. It @@ -1204,6 +1209,13 @@ ls.add_snippets("all", { }, { delimiters = "<>" })), + s("example4", fmt([[ + repeat {a} with the same key {a} + ]], { + a = i(1, "this will be repeat") + }, { + repeat_duplicates = true + })) }) ``` @@ -1238,6 +1250,9 @@ any way, correspond to the jump-index of the nodes! when passing multiline strings via `[[]]` (default true). * `dedent`: remove indent common to all lines in `format`. Again, makes passing multiline-strings a bit nicer (default true). + * `repeat_duplicates`: repeat nodes when a key is reused instead of copying + the node if it has a jump-index, refer to [jump-index](#jump-index) to + know which nodes have a jump-index (default false). There is also `require("luasnip.extras.fmt").fmta`. This only differs from `fmt` by using angle-brackets (`<>`) as the default-delimiter. diff --git a/Makefile b/Makefile index 6f546ffe8..bc9a54aa7 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,5 @@ NVIM_PATH=deps/nvim +TEST_FILE?=$(realpath tests) nvim: git clone --depth 1 https://github.com/neovim/neovim ${NVIM_PATH} || (cd ${NVIM_PATH}; git fetch --depth 1; git checkout origin/master) @@ -33,4 +34,4 @@ test: nvim jsregexp # unset both to prevent env leaking into the neovim-build. # add helper-functions to lpath. # ";;" in CPATH appends default. - unset LUA_PATH LUA_CPATH; LUASNIP_SOURCE=$(shell pwd) JSREGEXP_PATH=$(shell pwd)/${JSREGEXP_PATH} TEST_FILE=$(realpath tests) BUSTED_ARGS=--lpath=$(shell pwd)/tests/?.lua make -C ${NVIM_PATH} functionaltest + unset LUA_PATH LUA_CPATH; LUASNIP_SOURCE=$(shell pwd) JSREGEXP_PATH=$(shell pwd)/${JSREGEXP_PATH} TEST_FILE=$(realpath ${TEST_FILE}) BUSTED_ARGS=--lpath=$(shell pwd)/tests/?.lua make -C ${NVIM_PATH} functionaltest diff --git a/doc/luasnip.txt b/doc/luasnip.txt index 4a75483e2..23db337bf 100644 --- a/doc/luasnip.txt +++ b/doc/luasnip.txt @@ -7,8 +7,9 @@ Table of Contents *luasnip-table-of-contents* - Jump-index |luasnip-jump-index| - Adding Snippets |luasnip-adding-snippets| 2. NODE |luasnip-node| + - Node-Api: |luasnip-node-api:| 3. SNIPPETS |luasnip-snippets| - - Api: |luasnip-api:| + - Snippet-Api: |luasnip-snippet-api:| 4. TEXTNODE |luasnip-textnode| 5. INSERTNODE |luasnip-insertnode| 6. FUNCTIONNODE |luasnip-functionnode| @@ -193,6 +194,13 @@ only apply to some nodes (`user_args` for both function and dynamicNode). These `opts` are only mentioned if they accept options that are not common to all nodes. +NODE-API: *luasnip-node-api:* + + +- `get_jump_index()`: this method returns the jump-index of a node. If a node + doesn’t have a jump-index, this method returns `nil` instead. + + ============================================================================== 3. SNIPPETS *luasnip-snippets* @@ -304,7 +312,7 @@ where the snippet can be accessed through the immediate parent parent.snippet`, but the `parent` of the dynamicNode is not always the surrounding snippet, it could be a `snippetNode`). -API: *luasnip-api:* +SNIPPET-API: *luasnip-snippet-api:* - `invalidate()`: call this method to effectively remove the snippet. The @@ -1167,6 +1175,13 @@ Simple example: }, { delimiters = "<>" })), + s("example4", fmt([[ + repeat {a} with the same key {a} + ]], { + a = i(1, "this will be repeat") + }, { + repeat_duplicates = true + })) }) < @@ -1197,6 +1212,9 @@ any way, correspond to the jump-index of the nodes! when passing multiline strings via `[[]]` (default true). - `dedent`: remove indent common to all lines in `format`. Again, makes passing multiline-strings a bit nicer (default true). + - `repeat_duplicates`: repeat nodes when a key is reused instead of copying + the node if it has a jump-index, refer to |luasnip-jump-index| to + know which nodes have a jump-index (default false). There is also `require("luasnip.extras.fmt").fmta`. This only differs from diff --git a/lua/luasnip/extras/fmt.lua b/lua/luasnip/extras/fmt.lua index cf9ae3e71..4d8e31527 100644 --- a/lua/luasnip/extras/fmt.lua +++ b/lua/luasnip/extras/fmt.lua @@ -2,6 +2,7 @@ local text_node = require("luasnip.nodes.textNode").T local wrap_nodes = require("luasnip.util.util").wrap_nodes local extend_decorator = require("luasnip.util.extend_decorator") local Str = require("luasnip.util.str") +local rp = require("luasnip.extras").rep -- https://gist.github.com/tylerneylon/81333721109155b2d244 local function copy3(obj, seen) @@ -39,11 +40,13 @@ end -- opts: -- delimiters: string, 2 distinct characters (left, right), default "{}" -- strict: boolean, set to false to allow for unused `args`, default true +-- repeat_duplicates: boolean, repeat nodes which have jump_index instead of copying them, default false -- Returns: a list of strings and elements of `args` inserted into placeholders local function interpolate(fmt, args, opts) local defaults = { delimiters = "{}", strict = true, + repeat_duplicates = false, } opts = vim.tbl_extend("force", defaults, opts or {}) @@ -97,7 +100,12 @@ local function interpolate(fmt, args, opts) -- The nodes are modified in-place as part of constructing the snippet, -- modifying one node twice will lead to UB. if used_keys[key] then - table.insert(elements, copy3(args[key])) + local jump_index = args[key]:get_jump_index() -- For nodes that don't have a jump index, copy it instead + if not opts.repeat_duplicates or jump_index == nil then + table.insert(elements, copy3(args[key])) + else + table.insert(elements, rp(jump_index)) + end else table.insert(elements, args[key]) used_keys[key] = true @@ -211,6 +219,7 @@ local function format_nodes(str, nodes, opts) end end, parts) end + extend_decorator.register(format_nodes, { arg_indx = 3 }) return { diff --git a/lua/luasnip/nodes/node.lua b/lua/luasnip/nodes/node.lua index cdfe5d4a6..4d9cd5b62 100644 --- a/lua/luasnip/nodes/node.lua +++ b/lua/luasnip/nodes/node.lua @@ -216,6 +216,7 @@ Node.update_dependents_static = Node._update_dependents_static Node.update_all_dependents_static = Node._update_dependents_static function Node:update() end + function Node:update_static() end function Node:expand_tabs(tabwidth, indentstr) @@ -298,6 +299,10 @@ function Node:get_static_args() return get_args(self, "get_static_text") end +function Node:get_jump_index() + return self.pos +end + function Node:set_ext_opts(name) -- differentiate, either visited or unvisited needs to be set. if name == "passive" then diff --git a/tests/integration/fmt_spec.lua b/tests/integration/fmt_spec.lua new file mode 100644 index 000000000..a237d4434 --- /dev/null +++ b/tests/integration/fmt_spec.lua @@ -0,0 +1,58 @@ +local helpers = require("test.functional.helpers")(after_each) +local exec_lua, feed = helpers.exec_lua, helpers.feed +local ls_helpers = require("helpers") +local Screen = require("test.functional.ui.screen") + +describe("Fmt", function() + local screen + + before_each(function() + helpers.clear() + ls_helpers.session_setup_luasnip() + screen = Screen.new(50, 3) + screen:attach() + screen:set_default_attr_ids({ + [0] = { bold = true, foreground = Screen.colors.Blue }, + [1] = { bold = true, foreground = Screen.colors.Brown }, + [2] = { bold = true }, + [3] = { background = Screen.colors.LightGray }, + }) + end) + after_each(function() + screen:detach() + end) + + it("Repeat duplicate node with same key", function() + exec_lua([=[ + ls.add_snippets("all", { + ls.s( + "repeat", + require("luasnip.extras.fmt").fmt([[ + {a} repeat {a} + ]], + { a = ls.i(1) }, + { repeat_duplicates = true } + ) + ) + }) + ]=]) + feed("irepeat") + exec_lua("ls.expand()") + screen:expect({ + grid = [[ + ^ repeat | + {0:~ }| + {2:-- INSERT --} | + ]], + }) + feed("asdf") + exec_lua("ls.jump()") + screen:expect({ + grid = [[ + ^asdf repeat asdf | + {0:~ }| + {2:-- INSERT --} | + ]], + }) + end) +end) diff --git a/tests/unit/fmt_spec.lua b/tests/unit/fmt_spec.lua index 0bc7a3251..3990fe998 100644 --- a/tests/unit/fmt_spec.lua +++ b/tests/unit/fmt_spec.lua @@ -1,20 +1,44 @@ local helpers = require("test.functional.helpers")(after_each) -local works = function(msg, fmt, args, expected, opts) - it(msg, function() +---@param params { msg: string, fmt: string, args: string, expected: string, opts: string, prolouge: string} +local works = function(params) + it(params.msg, function() -- normally `exec_lua` accepts a table which is passed to the code as `...`, but it -- fails if number- and string-keys are mixed ({1,2} works fine, {1, b=2} fails). -- So we limit ourselves to just passing strings, which are then turned into tables -- while load()ing the function. - local result = helpers.exec_lua( - string.format( - 'return require("luasnip.extras.fmt").interpolate("%s", %s, %s)', - fmt, - args, - opts - ) - ) - assert.are.same(expected, table.concat(result)) + local result = helpers.exec_lua(string.format( + [[ + local args = %s + local mock_args = {} + + local fake_metatable = { + __tostring = function(self) + return self.value + end, + get_jump_index = function(self) return nil end + } + fake_metatable.__index = fake_metatable + + local fake_node = function(value) + return setmetatable({value = value}, fake_metatable) + end + for key, arg in pairs(args) do + mock_args[key] = fake_node(arg) + end + local result = require("luasnip.extras.fmt").interpolate("%s", mock_args, %s) + + local str_result = "" + for _, value in pairs(result) do + str_result = str_result .. tostring(value) + end + return str_result + ]], + params.args, + params.fmt, + params.opts + )) + assert.are.same(params.expected, result) end) end @@ -39,118 +63,145 @@ describe("fmt.interpolate", function() -- set in makefile. helpers.exec("set rtp+=" .. os.getenv("LUASNIP_SOURCE")) - works("expands with no numbers", "a{}b{}c{}d", "{ 4, 5, 6 }", "a4b5c6d") - - works( - "expands with explicit numbers", - "a{2}b{1}c{3}d", - "{ 4, 5, 6 }", - "a5b4c6d" - ) - - works( - "expands with mixed numbering", - "a{}b{3}c{}d{2}e", - "{ 1, 2, 3, 4 }", - "a1b3c4d2e" - ) - - works( - "expands named placeholders", - "a{A}b{B}c{C}d", - "{ A = 1, B = 2, C = 3 }", - "a1b2c3d" - ) - - works( - "expands all mixed", - "a {A} b {} c {3} d {} e {B} f {A} g {2} h", - "{ 1, 2, 3, 4, A = 10, B = 20 }", - "a 10 b 1 c 3 d 4 e 20 f 10 g 2 h" - ) - - works( - "current index changed by numbered nodes", - "{} {} {1} {} {}", - "{ 1, 2, 3 }", - "1 2 1 2 3" - ) - - works("excludes trailing text", "{}abcd{}", "{ 1, 2 }", "1abcd2") - - works( - "escapes empty double-braces", - "a{{}}b{}c{{}}d{}e", - "{ 2, 4 }", - "a{}b2c{}d4e" - ) - - works("escapes non-empty double-braces", "a{{d}}b{}c", "{ 2 }", "a{d}b2c") - - works( - "do not trim placeholders with whitespace", - "a{ something}b{}c", - '{ 2, [" something"] = 1 }', - "a1b2c" - ) - - works( - "replaces nested escaped braces", - "a{{{{}}}}b{}c{{ {{ }}}}d", - "{ 2 }", - "a{{}}b2c{ { }}d" - ) - - works("replaces umatched escaped braces", "a{{{{b{}c", "{ 2 }", "a{{b2c") - - works( - "replaces in braces inside escaped braces", - "a{{{}}}b{{ {}}}c{{{} }}d{{ {} }}e", - "{ 1, 2, 3, 4 }", - "a{1}b{ 2}c{3 }d{ 4 }e" - ) + works({ + msg = "expands with no numbers", + fmt = "a{}b{}c{}d", + args = "{ 4, 5, 6 }", + expected = "a4b5c6d", + }) + + works({ + msg = "expands with explicit numbers", + fmt = "a{2}b{1}c{3}d", + args = "{ 4, 5, 6 }", + expected = "a5b4c6d", + }) + + works({ + msg = "expands with mixed numbering", + fmt = "a{}b{3}c{}d{2}e", + args = "{ 1, 2, 3, 4 }", + expected = "a1b3c4d2e", + }) + + works({ + msg = "expands named placeholders", + fmt = "a{A}b{B}c{C}d", + args = "{ A = 1, B = 2, C = 3 }", + expected = "a1b2c3d", + }) + + works({ + msg = "expands all mixed", + fmt = "a {A} b {} c {3} d {} e {B} f {A} g {2} h", + args = "{ 1, 2, 3, 4, A = 10, B = 20 }", + expected = "a 10 b 1 c 3 d 4 e 20 f 10 g 2 h", + }) + + works({ + msg = "current index changed by numbered nodes", + fmt = "{} {} {1} {} {}", + args = "{ 1, 2, 3 }", + expected = "1 2 1 2 3", + }) + + works({ + msg = "excludes trailing text", + fmt = "{}abcd{}", + args = "{ 1, 2 }", + expected = "1abcd2", + }) + + works({ + msg = "escapes empty double-braces", + fmt = "a{{}}b{}c{{}}d{}e", + args = "{ 2, 4 }", + expected = "a{}b2c{}d4e", + }) + + works({ + msg = "escapes non-empty double-braces", + fmt = "a{{d}}b{}c", + args = "{ 2 }", + expected = "a{d}b2c", + }) + + works({ + msg = "do not trim placeholders with whitespace", + fmt = "a{ something}b{}c", + args = '{ 2, [" something"] = 1 }', + expected = "a1b2c", + }) + + works({ + msg = "replaces nested escaped braces", + fmt = "a{{{{}}}}b{}c{{ {{ }}}}d", + args = "{ 2 }", + expected = "a{{}}b2c{ { }}d", + }) + + works({ + msg = "replaces umatched escaped braces", + fmt = "a{{{{b{}c", + args = "{ 2 }", + expected = "a{{b2c", + }) + + works({ + msg = "replaces in braces inside escaped braces", + fmt = "a{{{}}}b{{ {}}}c{{{} }}d{{ {} }}e", + args = "{ 1, 2, 3, 4 }", + expected = "a{1}b{ 2}c{3 }d{ 4 }e", + }) + + works({ + msg = "repeats node with default options", + fmt = "{a}{a}", + args = "{a = 1}", + expected = "11", + }) fails("fails for unbalanced braces", "a{b", {}) fails("fails for nested braces", "a{ { } }b", {}) - works( - "can use different delimiters", - "foo() { return <>; };", - "{ 10 }", - "foo() { return 10; };", - '{ delimiters = "<>" }' - ) + works({ + msg = "can use different delimiters", + fmt = "foo() { return <>; };", + args = "{ 10 }", + expected = "foo() { return 10; };", + opts = '{ delimiters = "<>" }', + }) local delimiters = { "()", "[]", "<>", "%$", "#@", "?!" } for _, delims in ipairs(delimiters) do local left, right = delims:sub(1, 1), delims:sub(2, 2) describe("can use custom delimiters", function() - works( - delims, - string.format("{ return %s%s; };", left, right), - "{ 10 }", - "{ return 10; };", - string.format('{ delimiters = "%s" }', delims) - ) + works({ + msg = delims, + fmt = string.format("{ return %s%s; };", left, right), + args = "{ 10 }", + expected = "{ return 10; };", + opts = string.format('{ delimiters = "%s" }', delims), + }) end) end - works( - "can escape custom delimiters", - "foo((x)) { return x + (); };", - "{ 10 }", - "foo(x) { return x + 10; };", - '{ delimiters = "()" }' - ) - - works( - "can use named placeholders with custom delimiters", - "foo(x) { return x + [y]; };", - "{ y = 10 }", - "foo(x) { return x + 10; };", - '{ delimiters = "[]" }' - ) + works({ + msg = "can escape custom delimiters", + fmt = "foo((x)) { return x + (); };", + args = "{ 10 }", + expected = "foo(x) { return x + 10; };", + opts = '{ delimiters = "()" }', + }) + + works({ + msg = "can use named placeholders with custom delimiters", + fmt = "foo(x) { return x + [y]; };", + args = "{ y = 10 }", + expected = "foo(x) { return x + 10; };", + opts = '{ delimiters = "[]" }', + }) fails("dissallows unused list args", "a {} b {} c", "{ 1, 2, 3 }") @@ -160,11 +211,11 @@ describe("fmt.interpolate", function() "{ 1, A = 10, B = 20, C = 30 }" ) - works( - "allows unused with strict=false", - "a {A} b {B} c {} d", - "{ 1, 2, A = 10, B = 20, C = 30 }", - "a 10 b 20 c 1 d", - "{ strict = false }" - ) + works({ + msg = "allows unused with strict=false", + fmt = "a {A} b {B} c {} d", + args = "{ 1, 2, A = 10, B = 20, C = 30 }", + expected = "a 10 b 20 c 1 d", + opts = "{ strict = false }", + }) end)