From 3bfec246c8c8f4a26eecd0895ab16f3d4b24a055 Mon Sep 17 00:00:00 2001 From: TheLeoP Date: Thu, 21 May 2026 21:46:27 -0500 Subject: [PATCH 1/2] feat(ai): check children treesitter languages after parents Details: - Users may expect next/previous textobjects in injected languages located before/after the cursor to work, even if they are not part of the LanguageTree under cursor or its ancestors. This is specially important for languages that rely heavily on injections like markdown (injecting markdown_inline). - So, we fallback to searching in injected children languages if no textobject is found for current LanguageTree or its ancestors. Resolve #2397 Co-authored-by: Evgeni Chasnovski --- doc/mini-ai.txt | 4 ++++ lua/mini/ai.lua | 33 ++++++++++++++++++++++++++------- tests/test_ai.lua | 10 ++++++++++ 3 files changed, 40 insertions(+), 7 deletions(-) diff --git a/doc/mini-ai.txt b/doc/mini-ai.txt index 5fc60f8c1..e51734998 100644 --- a/doc/mini-ai.txt +++ b/doc/mini-ai.txt @@ -798,6 +798,10 @@ Notes: Verify with `:=vim.treesitter.query.get('lang', 'textobjects')` and see if the target capture is recognized as one. - It uses buffer's |filetype| to determine query language. +- It first searches the language under cursor for matches. If no matches are + found, it fallbacks to searching parent languages (up to the buffer's root + language). If no matches are found again, it fallbacks to recursively + searching all children languages (from the language under cursor). - On large files it is slower than pattern-based textobjects. Still very fast though (one search should be magnitude of milliseconds or tens of milliseconds on really large file). diff --git a/lua/mini/ai.lua b/lua/mini/ai.lua index 20b6106aa..b3ee43431 100644 --- a/lua/mini/ai.lua +++ b/lua/mini/ai.lua @@ -993,6 +993,10 @@ end --- Verify with `:=vim.treesitter.query.get('lang', 'textobjects')` and see --- if the target capture is recognized as one. --- - It uses buffer's |filetype| to determine query language. +--- - It first searches the language under cursor for matches. If no matches are +--- found, it fallbacks to searching parent languages (up to the buffer's root +--- language). If no matches are found again, it fallbacks to recursively +--- searching all children languages (from the language under cursor). --- - On large files it is slower than pattern-based textobjects. Still very --- fast though (one search should be magnitude of milliseconds or tens of --- milliseconds on really large file). @@ -1608,22 +1612,29 @@ H.get_matched_ranges_builtin = function(captures) -- Get parser (LanguageTree) at cursor (important for injected languages) local pos = vim.api.nvim_win_get_cursor(0) local lang_tree = parser:language_for_range({ pos[1] - 1, pos[2], pos[1] - 1, pos[2] }) + local init_lang_tree = lang_tree local missing_query_langs = {} local res = {} -- Maybe go up parent trees to work with injected languages while vim.tbl_isempty(res) and lang_tree ~= nil do - local lang = lang_tree:lang() - -- Get query file depending on the local language - local query = vim.treesitter.query.get(lang, 'textobjects') + H.append_lang_ranges(res, missing_query_langs, buf_id, captures, lang_tree) - if query ~= nil then H.append_ranges(res, buf_id, query, captures, lang_tree) end - if query == nil then missing_query_langs[lang] = true end - - -- `LanguageTree:parent()` was added in Neovim<0.10 + -- `LanguageTree:parent()` was added in Neovim=0.10 -- TODO: Drop extra check after compatibility with Neovim=0.9 is dropped lang_tree = lang_tree.parent and lang_tree:parent() or nil end + -- Fallback to children trees for injected languages + if vim.tbl_isempty(res) then + local check_children + check_children = function(l_tree) + for _, child in pairs(l_tree:children()) do + H.append_lang_ranges(res, missing_query_langs, buf_id, captures, child) + check_children(child) + end + end + check_children(init_lang_tree) + end if vim.tbl_isempty(res) and not vim.tbl_isempty(missing_query_langs) then H.error_treesitter('query', vim.tbl_keys(missing_query_langs)) @@ -1632,6 +1643,14 @@ H.get_matched_ranges_builtin = function(captures) return res end +H.append_lang_ranges = function(res, missing_query_langs, buf_id, captures, lang_tree) + local lang = lang_tree:lang() + local query = vim.treesitter.query.get(lang, 'textobjects') + + if query ~= nil then H.append_ranges(res, buf_id, query, captures, lang_tree) end + if query == nil then missing_query_langs[lang] = true end +end + H.append_ranges = function(res, buf_id, query, captures, lang_tree) -- Compute ranges of matched captures local capture_is_requested = vim.tbl_map(function(c) return vim.tbl_contains(captures, '@' .. c) end, query.captures) diff --git a/tests/test_ai.lua b/tests/test_ai.lua index 4187d32e7..57b24ddf2 100644 --- a/tests/test_ai.lua +++ b/tests/test_ai.lua @@ -857,6 +857,16 @@ T['gen_spec']['treesitter()']['works with parent of injected language'] = functi validate_find(lines, { 3, 0 }, { 'a', 'F' }, { { 1, 13 }, { 5, 3 } }) end +T['gen_spec']['treesitter()']['works with injected child language'] = function() + local lines = { + 'vim.cmd([[', + 'set cursorline', + 'lua local a = function() return true end', + ']])', + } + validate_find(lines, { 1, 0 }, { 'a', 'F' }, { { 3, 15 }, { 3, 40 } }) +end + T['gen_spec']['treesitter()']['works with row-exclusive, col-0 end range'] = function() child.lua([[MiniAi.config.custom_textobjects = { c = MiniAi.gen_spec.treesitter({ a = '@chunk.outer', i = '@chunk.outer' }), From 18236abfa305f7daff7102abe8201f135950fcfe Mon Sep 17 00:00:00 2001 From: TheLeoP Date: Thu, 21 May 2026 22:00:42 -0500 Subject: [PATCH 2/2] feat(surround): check children treesitter languages after parents Details: - Users may expect next/previous textobjects in injected languages located before/after the cursor to work, even if they are not part of the LanguageTree under cursor or its ancestors. This is specially important for languages that rely heavily on injections like markdown (injecting markdown_inline). - So, we fallback to searching in injected children languages if no textobject is found for current LanguageTree or its ancestors. Co-authored-by: Evgeni Chasnovski --- doc/mini-surround.txt | 4 ++++ lua/mini/surround.lua | 53 ++++++++++++++++++++++++++++------------- tests/test_surround.lua | 10 ++++++++ 3 files changed, 51 insertions(+), 16 deletions(-) diff --git a/doc/mini-surround.txt b/doc/mini-surround.txt index 931749ab6..1daf63282 100644 --- a/doc/mini-surround.txt +++ b/doc/mini-surround.txt @@ -824,6 +824,10 @@ Notes: Verify with `:=vim.treesitter.query.get('lang', 'textobjects')` and see if the target capture is recognized as one. - It uses buffer's |filetype| to determine query language. +- It first searches the language under cursor for matches. If no matches are + found, it fallbacks to searching parent languages (up to the buffer's root + language). If no matches are found again, it fallbacks to recursively + searching all children languages (from the language under cursor). - On large files it is slower than pattern-based textobjects. Still very fast though (one search should be magnitude of milliseconds or tens of milliseconds on really large file). diff --git a/lua/mini/surround.lua b/lua/mini/surround.lua index 671419482..e0f5a48c9 100644 --- a/lua/mini/surround.lua +++ b/lua/mini/surround.lua @@ -1006,6 +1006,10 @@ MiniSurround.gen_spec = { input = {}, output = {} } --- Verify with `:=vim.treesitter.query.get('lang', 'textobjects')` and see --- if the target capture is recognized as one. --- - It uses buffer's |filetype| to determine query language. +--- - It first searches the language under cursor for matches. If no matches are +--- found, it fallbacks to searching parent languages (up to the buffer's root +--- language). If no matches are found again, it fallbacks to recursively +--- searching all children languages (from the language under cursor). --- - On large files it is slower than pattern-based textobjects. Still very --- fast though (one search should be magnitude of milliseconds or tens of --- milliseconds on really large file). @@ -1545,35 +1549,36 @@ H.get_matched_range_pairs_builtin = function(captures) -- Get parser (LanguageTree) at cursor (important for injected languages) local pos = vim.api.nvim_win_get_cursor(0) local lang_tree = parser:language_for_range({ pos[1] - 1, pos[2], pos[1] - 1, pos[2] }) + local init_lang_tree = lang_tree local missing_query_langs = {} -- Compute matched ranges for both outer and inner captures -- Maybe go up parent trees to work with injected languages - local outer_ranges, inner_ranges = {}, {} - while (vim.tbl_isempty(inner_ranges) or vim.tbl_isempty(outer_ranges)) and lang_tree ~= nil do - local lang = lang_tree:lang() - -- Get query file depending on the local language - local query = vim.treesitter.query.get(lang, 'textobjects') - - if query ~= nil then - for _, tree in ipairs(lang_tree:trees()) do - local root = tree:root() - vim.list_extend(outer_ranges, H.get_match_ranges_builtin(root, buf_id, query, captures.outer:sub(2))) - vim.list_extend(inner_ranges, H.get_match_ranges_builtin(root, buf_id, query, captures.inner:sub(2))) - end - end - if query == nil then missing_query_langs[lang] = true end + local outer, inner = {}, {} + while (vim.tbl_isempty(inner) or vim.tbl_isempty(outer)) and lang_tree ~= nil do + H.append_lang_ranges(outer, inner, missing_query_langs, buf_id, captures, lang_tree) -- `LanguageTree:parent()` was added in Neovim<0.10 -- TODO: Drop extra check after compatibility with Neovim=0.9 is dropped lang_tree = lang_tree.parent and lang_tree:parent() or nil end + -- Fallback to children trees for injected languages + if vim.tbl_isempty(inner) or vim.tbl_isempty(outer) then + local check_children + check_children = function(l_tree) + for _, child in pairs(l_tree:children()) do + H.append_lang_ranges(outer, inner, missing_query_langs, buf_id, captures, child) + check_children(child) + end + end + check_children(init_lang_tree) + end -- Match outer and inner ranges: for each outer range pick the biggest inner -- range that lies within outer local res = {} - for i, outer in ipairs(outer_ranges) do - res[i] = { outer = outer, inner = H.get_biggest_nested_range(inner_ranges, outer) } + for i, o in ipairs(outer) do + res[i] = { outer = o, inner = H.get_biggest_nested_range(inner, o) } end if vim.tbl_isempty(res) and not vim.tbl_isempty(missing_query_langs) then @@ -1583,6 +1588,22 @@ H.get_matched_range_pairs_builtin = function(captures) return res end +H.append_lang_ranges = function(outer, inner, missing_query_langs, buf_id, captures, lang_tree) + local lang = lang_tree:lang() + local query = vim.treesitter.query.get(lang, 'textobjects') + + if query ~= nil then H.append_ranges(outer, inner, buf_id, query, captures, lang_tree) end + if query == nil then missing_query_langs[lang] = true end +end + +H.append_ranges = function(outer, inner, buf_id, query, captures, lang_tree) + for _, tree in ipairs(lang_tree:trees()) do + local root = tree:root() + vim.list_extend(outer, H.get_match_ranges_builtin(root, buf_id, query, captures.outer:sub(2))) + vim.list_extend(inner, H.get_match_ranges_builtin(root, buf_id, query, captures.inner:sub(2))) + end +end + H.get_match_ranges_builtin = function(root, buf_id, query, capture) local res = {} -- TODO: Remove `opts.all`after compatibility with Neovim=0.10 is dropped diff --git a/tests/test_surround.lua b/tests/test_surround.lua index 3ba418985..da15487b7 100644 --- a/tests/test_surround.lua +++ b/tests/test_surround.lua @@ -412,6 +412,16 @@ T['gen_spec']['input']['treesitter()']['works with parent of injected language'] validate_no_find(lines, { 1, 0 }, type_keys, 'sf', 'F') end +T['gen_spec']['input']['treesitter()']['works with injected child language'] = function() + local lines = { + 'vim.cmd([[', + 'set cursorline', + 'lua local a = function() return true end', + ']])', + } + validate_find(lines, { 1, 0 }, { { 3, 14 } }, type_keys, 'sfn', 'F') +end + T['gen_spec']['input']['treesitter()']['respects `opts.use_nvim_treesitter`'] = function() child.lua([[MiniSurround.config.custom_surroundings = { F = { input = MiniSurround.gen_spec.input.treesitter({ outer = '@function.outer', inner = '@function.inner' }) },