Skip to content

Commit

Permalink
fix: injected parser shouldn't format combined injections (#205)
Browse files Browse the repository at this point in the history
  • Loading branch information
stevearc committed Nov 18, 2023
1 parent c2963fd commit eeef888
Show file tree
Hide file tree
Showing 2 changed files with 45 additions and 11 deletions.
2 changes: 2 additions & 0 deletions doc/advanced_topics.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ The way this "aftermarket" range formatting works is conform will format the ent

## Injected language formatting (code blocks)

Requires: Neovim 0.9+

Sometimes you may have a file that contains small chunks of code in another language. This is most common for markup formats like markdown and neorg, but can theoretically be present in any filetype (for example, embedded SQL queries in a host language). For files like this, it would be nice to be able to format these code chunks using their language-specific formatters.

The way that conform supports this is via the `injected` formatter. If you run this formatter on a file, it will use treesitter to parse out the blocks in the file that have different languages and runs the formatters for that filetype (configured with `formatters_by_ft`). The formatters are run in parallel, one job for each language block.
Expand Down
54 changes: 43 additions & 11 deletions lua/conform/formatters/injected.lua
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,8 @@ return {
},
condition = function(self, ctx)
local ok = pcall(vim.treesitter.get_parser, ctx.buf)
return ok
-- Require Neovim 0.9 because the treesitter API has changed significantly
return ok and vim.fn.has("nvim-0.9") == 1
end,
format = function(self, ctx, lines, callback)
local conform = require("conform")
Expand All @@ -93,24 +94,55 @@ return {
local options = self.options
--- Disable diagnostic to pass the typecheck github action
--- This is available on nightly, but not on stable
--- Stable doesn't have any parameters, so it's safe to always pass `true`
--- Stable doesn't have any parameters, so it's safe to always pass `false`
---@diagnostic disable-next-line: redundant-parameter
parser:parse(true)
parser:parse(false)
local root_lang = parser:lang()
local regions = {}
for lang, child_tree in pairs(parser:children()) do
local formatter_names = conform.formatters_by_ft[lang]
if formatter_names and lang ~= root_lang then
for _, tree in ipairs(child_tree:trees()) do
local root = tree:root()
local start_lnum = root:start() + 1
local end_lnum = root:end_()
if start_lnum <= end_lnum and in_range(ctx.range, start_lnum, end_lnum) then

for _, tree in pairs(parser:trees()) do
local root_node = tree:root()
local start_line, _, end_line, _ = root_node:range()

-- I don't like using these private methods, but critically we do _not_ want to format
-- "combined" injections (they contain the metadata "injection.combined"). These injections
-- will merge all of their regions into a single LanguageTree. If we then try to format the
-- range defined by that LanguageTree, we will likely end up with a range that contains all
-- sorts of content. As a concrete example, consider the following markdown:
-- This is some text
-- <!-- Here is a comment -->
-- Some more text
-- <!-- Another comment -->
-- Since the html injection is combined, the range will contain "Some more text", which is not
-- what we want.
-- To avoid this, don't parse with injections. Instead, we use private methods to run the
-- injection queries ourselves, and then filter out the combined injections.
for _, match, metadata in
---@diagnostic disable-next-line: invisible
parser._injection_query:iter_matches(root_node, text, start_line, end_line + 1)
do
---@diagnostic disable-next-line: invisible
local lang, combined, ranges = parser:_get_injection(match, metadata)
local has_formatters = conform.formatters_by_ft[lang] ~= nil
if lang and has_formatters and not combined and #ranges > 0 and lang ~= root_lang then
local start_lnum
local end_lnum
-- Merge all of the ranges into a single range
for _, range in ipairs(ranges) do
if not start_lnum or start_lnum > range[1] + 1 then
start_lnum = range[1] + 1
end
if not end_lnum or end_lnum < range[4] then
end_lnum = range[4]
end
end
if in_range(ctx.range, start_lnum, end_lnum) then
table.insert(regions, { lang, start_lnum, end_lnum })
end
end
end
end

-- Sort from largest start_lnum to smallest
table.sort(regions, function(a, b)
return a[2] > b[2]
Expand Down

0 comments on commit eeef888

Please sign in to comment.