diff --git a/.gitignore b/.gitignore index 0c979ba4..6549d02f 100644 --- a/.gitignore +++ b/.gitignore @@ -48,3 +48,4 @@ tests/fake_formatter_output scripts/nvim_doc_tools scripts/nvim-typecheck-action tmp/ +.workspaces/ diff --git a/doc/formatter_options.md b/doc/formatter_options.md index 1dabab8f..37927f38 100644 --- a/doc/formatter_options.md +++ b/doc/formatter_options.md @@ -54,6 +54,14 @@ options = { teal = "tl", typescript = "ts", }, + interpolation_queries = { + nix = "(interpolation) @interp", + -- JS/TS template strings contain `${ ... }` substitutions. + javascript = "(template_substitution) @interp", + typescript = "(template_substitution) @interp", + jsx = "(template_substitution) @interp", + tsx = "(template_substitution) @interp", + }, -- Map of treesitter language to formatters to use -- (defaults to the value from formatters_by_ft) lang_to_formatters = {}, diff --git a/lua/conform/formatters/injected.lua b/lua/conform/formatters/injected.lua index 6f0cecb4..e6a92729 100644 --- a/lua/conform/formatters/injected.lua +++ b/lua/conform/formatters/injected.lua @@ -98,6 +98,24 @@ local function restore_surrounding(lines, surrounding) end end +---@param placeholders string[] +---@param indent string? +local function remove_surrounding_from_placeholders(placeholders, indent) + if not indent or #placeholders == 0 then + return + end + local n = #indent + for i, placeholder in ipairs(placeholders) do + local lines = vim.split(placeholder, "\n", { plain = true, trimempty = false }) + for j, line in ipairs(lines) do + if line ~= "" and line:sub(1, n) == indent then + lines[j] = line:sub(n + 1) + end + end + placeholders[i] = table.concat(lines, "\n") + end +end + ---Merge adjacent ranges that have the same language and share a prefix ---@param regions LangRange[] ---@param bufnr integer @@ -144,6 +162,346 @@ local function merge_ranges_with_prefix(regions, bufnr, buf_lang) return ret end +---@type table +local interpolation_queries = {} + +---@param bufnr integer +---@param row integer 0-indexed +---@param col integer 0-indexed +---@return string +local function buf_char_at(bufnr, row, col) + if col < 0 then + return "" + end + local line = vim.api.nvim_buf_get_lines(bufnr, row, row + 1, true)[1] or "" + return line:sub(col + 1, col + 1) +end + +---Some grammars (notably JS/TS) report the substitution node starting at `{` rather than `$`. +---@param bufnr integer +---@param row integer 0-indexed +---@param col integer 0-indexed +---@return integer +local function expand_interpolation_start(bufnr, row, col) + if col > 0 and buf_char_at(bufnr, row, col - 1) == "$" then + return col - 1 + end + return col +end + +---@param interpolation_query_strings table +---@param lang string +---@return vim.treesitter.Query? +local function get_interpolation_query(interpolation_query_strings, lang) + local qstr = interpolation_query_strings[lang] + if not qstr then + return nil + end + if interpolation_queries[lang] == nil then + local ok, q = pcall(vim.treesitter.query.parse, lang, qstr) + interpolation_queries[lang] = ok and q or false + end + return interpolation_queries[lang] or nil +end + +---@param lines string[]? +---@param placeholders string[] +---@return string[]? +local function restore_interpolations(lines, placeholders) + if not lines or #placeholders == 0 then + return lines + end + local text = table.concat(lines, "\n") + for i, placeholder in ipairs(placeholders) do + local token = string.format("__CONFORM_INTERPOLATION_%d__", i) + text = text:gsub(token, placeholder) + end + return vim.split(text, "\n", { plain = true, trimempty = false }) +end + +---@class (exact) InterpSpan +---@field sr integer +---@field sc integer +---@field er integer +---@field ec integer + +---@param query vim.treesitter.Query +---@param root_node TSNode +---@param bufnr integer +---@return InterpSpan[] +local function collect_interpolation_spans(query, root_node, bufnr) + local spans = {} ---@type InterpSpan[] + for id, node in query:iter_captures(root_node, bufnr, 0, -1) do + if query.captures[id] == "interp" then + local sr, sc, er, ec = node:range() + sc = expand_interpolation_start(bufnr, sr, sc) + table.insert(spans, { sr = sr, sc = sc, er = er, ec = ec }) + end + end + table.sort(spans, function(a, b) + if a.sr ~= b.sr then + return a.sr < b.sr + end + return a.sc < b.sc + end) + return spans +end + +---@param bufnr integer +---@param sr integer +---@param sc integer +---@param er integer +---@param ec integer +---@return string +local function get_buf_text_between(bufnr, sr, sc, er, ec) + if sr == er then + local line = vim.api.nvim_buf_get_lines(bufnr, sr, sr + 1, true)[1] or "" + return line:sub(sc + 1, ec) + end + local lines = vim.api.nvim_buf_get_lines(bufnr, sr, er + 1, true) + if #lines == 0 then + return "" + end + lines[1] = (lines[1] or ""):sub(sc + 1) + lines[#lines] = (lines[#lines] or ""):sub(1, ec) + return table.concat(lines, "\n") +end + +---@param bufnr integer +---@param interp_spans InterpSpan[] +---@param sr integer +---@param sc integer +---@param er integer +---@param ec integer +---@return boolean +local function gap_is_only_interpolations_and_whitespace(bufnr, interp_spans, sr, sc, er, ec) + local gap_text = get_buf_text_between(bufnr, sr, sc, er, ec) + if gap_text == "" then + return false + end + + -- Collect interpolation spans fully contained in the gap. + ---@type {s: integer, e: integer}[] + local contained = {} + local gap_lines = vim.split(gap_text, "\n", { plain = true, trimempty = false }) + local line_offsets = { 0 } + for i = 2, #gap_lines do + line_offsets[i] = line_offsets[i - 1] + #gap_lines[i - 1] + 1 + end + + ---@param row integer + ---@param col integer + ---@return integer + local function abs_idx(row, col) + local line_idx = (row - sr) + 1 + if line_idx < 1 then + return 1 + end + if line_idx > #gap_lines then + return #gap_text + 1 + end + local rel_col = col + if row == sr then + rel_col = col - sc + end + return line_offsets[line_idx] + rel_col + 1 + end + + for _, span in ipairs(interp_spans) do + local starts_in = span.sr > sr or (span.sr == sr and span.sc >= sc) + local ends_in = span.er < er or (span.er == er and span.ec <= ec) + if starts_in and ends_in then + table.insert(contained, { s = abs_idx(span.sr, span.sc), e = abs_idx(span.er, span.ec) }) + end + end + + if #contained == 0 then + return false + end + + table.sort(contained, function(a, b) + return a.s > b.s + end) + for _, span in ipairs(contained) do + gap_text = gap_text:sub(1, span.s - 1) .. gap_text:sub(span.e) + end + return gap_text:match("^%s*$") ~= nil +end + +---@param regions LangRange[] +---@param bufnr integer +---@param interp_spans InterpSpan[] +---@return LangRange[] +local function merge_ranges_separated_by_interpolations(regions, bufnr, interp_spans) + if #interp_spans == 0 then + return regions + end + table.sort(regions, function(a, b) + if a[2] ~= b[2] then + return a[2] < b[2] + end + return a[3] < b[3] + end) + + local merged = {} ---@type LangRange[] + local cur = nil ---@type LangRange? + for _, r in ipairs(regions) do + if not cur then + cur = vim.deepcopy(r) + else + local same_lang = cur[1] == r[1] + local gap_ok = false + if same_lang then + local sr, sc = cur[4] - 1, cur[5] + local er, ec = r[2] - 1, r[3] + -- Only consider forward gaps + if er > sr or (er == sr and ec >= sc) then + gap_ok = gap_is_only_interpolations_and_whitespace(bufnr, interp_spans, sr, sc, er, ec) + end + end + if same_lang and gap_ok then + cur[4] = r[4] + cur[5] = r[5] + else + table.insert(merged, cur) + cur = vim.deepcopy(r) + end + end + end + if cur then + table.insert(merged, cur) + end + return merged +end + +---@param query vim.treesitter.Query +---@param root_node TSNode +---@param bufnr integer +---@param input_lines string[] +---@param region_start_row integer 0-indexed +---@param region_start_col integer 0-indexed +---@param region_end_row integer 0-indexed +---@param region_end_col integer 0-indexed +---@return string[] new_lines, string[] placeholders +local function protect_interpolations( + query, + root_node, + bufnr, + input_lines, + region_start_row, + region_start_col, + region_end_row, + region_end_col +) + if #input_lines == 0 then + return input_lines, {} + end + + ---@type {sr: integer, sc: integer, er: integer, ec: integer, text: string}[] + local spans = {} + for id, node in query:iter_captures(root_node, bufnr, region_start_row, region_end_row + 1) do + if query.captures[id] == "interp" then + local sr, sc, er, ec = node:range() + sc = expand_interpolation_start(bufnr, sr, sc) + + local starts_in = sr > region_start_row or (sr == region_start_row and sc >= region_start_col) + local ends_in = er < region_end_row or (er == region_end_row and ec <= region_end_col) + if starts_in and ends_in then + local parts = vim.api.nvim_buf_get_text(bufnr, sr, sc, er, ec, {}) + local text = table.concat(parts, "\n") + if text ~= "" then + table.insert(spans, { sr = sr, sc = sc, er = er, ec = ec, text = text }) + end + end + end + end + + if #spans == 0 then + return input_lines, {} + end + + local function starts_before(a, b) + if a.sr ~= b.sr then + return a.sr < b.sr + end + return a.sc < b.sc + end + local function ends_after(a, b) + if a.er ~= b.er then + return a.er > b.er + end + return a.ec > b.ec + end + local function within(a, b) + -- a is within b + return (a.sr > b.sr or (a.sr == b.sr and a.sc >= b.sc)) + and (a.er < b.er or (a.er == b.er and a.ec <= b.ec)) + end + + -- Prefer outermost spans if the query returns nested captures. + table.sort(spans, function(a, b) + if starts_before(a, b) then + return true + elseif starts_before(b, a) then + return false + end + return ends_after(a, b) + end) + + local filtered = {} ---@type typeof(spans) + local cur = nil ---@type {sr: integer, sc: integer, er: integer, ec: integer}? + for _, span in ipairs(spans) do + if not cur or not within(span, cur) then + table.insert(filtered, span) + cur = { sr = span.sr, sc = span.sc, er = span.er, ec = span.ec } + end + end + + -- Replace from the end so earlier positions stay valid. + table.sort(filtered, function(a, b) + if a.sr ~= b.sr then + return a.sr > b.sr + end + return a.sc > b.sc + end) + + local placeholders = {} ---@type string[] + + ---@param row integer 0-indexed + ---@param col integer 0-indexed + ---@return integer + local function rel_col(row, col) + if row == region_start_row then + return col - region_start_col + end + return col + end + + for _, span in ipairs(filtered) do + table.insert(placeholders, span.text) + local token = string.format("__CONFORM_INTERPOLATION_%d__", #placeholders) + + local rel_sr = (span.sr - region_start_row) + 1 + local rel_er = (span.er - region_start_row) + 1 + local rel_sc = rel_col(span.sr, span.sc) + local rel_ec = rel_col(span.er, span.ec) + + local start_line = input_lines[rel_sr] or "" + local end_line = input_lines[rel_er] or "" + local prefix = start_line:sub(1, rel_sc) + local suffix = end_line:sub(rel_ec + 1) + + input_lines[rel_sr] = prefix .. token .. suffix + -- Collapse the spanned lines into the start line so restoring the placeholder's + -- embedded newlines doesn't duplicate newlines from the lines array. + for i = rel_er, rel_sr + 1, -1 do + table.remove(input_lines, i) + end + end + + return input_lines, placeholders +end + ---@class (exact) LangRange ---@field [1] string language ---@field [2] integer start lnum @@ -170,6 +528,7 @@ end ---@field lang_to_ext table ---@field lang_to_ft table ---@field lang_to_formatters table +---@field interpolation_queries table ---@type conform.FileLuaFormatterConfig return { @@ -202,6 +561,15 @@ return { teal = "tl", typescript = "ts", }, + interpolation_queries = { + -- Nix strings can contain `${ ... }` interpolations. + nix = "(interpolation) @interp", + -- JS/TS template strings contain `${ ... }` substitutions. + javascript = "(template_substitution) @interp", + typescript = "(template_substitution) @interp", + jsx = "(template_substitution) @interp", + tsx = "(template_substitution) @interp", + }, -- Map of treesitter language to formatters to use -- (defaults to the value from formatters_by_ft) lang_to_formatters = {}, @@ -240,8 +608,16 @@ return { --- This is available on nightly, but not on stable --- Stable doesn't have any parameters, so it's safe ---@diagnostic disable-next-line: redundant-parameter - parser:parse(true) + local trees = parser:parse(true) local root_lang = parser:lang() + local root_tree = trees and trees[1] or nil + local root_node = root_tree and root_tree:root() or nil + local interpolation_query = root_node + and get_interpolation_query(options.interpolation_queries, root_lang) + or nil + local interpolation_spans = (interpolation_query and root_node) + and collect_interpolation_spans(interpolation_query, root_node, ctx.buf) + or {} ---@type LangRange[] local regions = {} @@ -263,6 +639,10 @@ return { end end + if #interpolation_spans > 0 then + regions = merge_ranges_separated_by_interpolations(regions, ctx.buf, interpolation_spans) + end + regions = merge_ranges_with_prefix(regions, ctx.buf, buf_lang) if ctx.range then @@ -385,7 +765,29 @@ return { local idx = num_format log.debug("Injected format %s:%d:%d: %s", lang, start_lnum, end_lnum, formatter_names) log.trace("Injected format lines %s", input_lines) + + -- If the host language supports string interpolations that can appear inside injected + -- blocks (e.g. nix `${...}`, JS/TS template substitutions), protect those nodes from the + -- injected formatter and restore them afterwards. + -- Important: this must run before remove_surrounding() so TreeSitter buffer coordinates + -- still line up with the region text. + local interpolation_placeholders = {} + if interpolation_query and root_node and lang ~= root_lang then + local rsr, rsc, rer, rec = start_lnum - 1, region[3], end_lnum - 1, end_col + input_lines, interpolation_placeholders = protect_interpolations( + interpolation_query, + root_node, + ctx.buf, + input_lines, + rsr, + rsc, + rer, + rec + ) + end + local surrounding = remove_surrounding(input_lines, buf_lang) + remove_surrounding_from_placeholders(interpolation_placeholders, surrounding.indent) -- Create a temporary buffer. This is only needed because some formatters rely on the file -- extension to determine a run mode (see https://github.com/stevearc/conform.nvim/issues/194) -- This is using lang_to_ext to map the language name to the file extension, and falls back @@ -398,8 +800,25 @@ return { vim.fn.bufload(buf) tmp_bufs[buf] = true local format_opts = { async = true, bufnr = buf, quiet = true } + log.trace( + "Injected formatter input for %s (%s): %s", + lang, + table.concat(formatter_names, ","), + table.concat(input_lines, "\\n") + ) conform.format_lines(formatter_names, input_lines, format_opts, function(err, new_lines) + if err then + log.error( + "Error formatting injected language %s:%d:%d with formatters %s: %s", + lang, + start_lnum, + end_lnum, + formatter_names, + err + ) + end log.trace("Injected %s:%d:%d formatted lines %s", lang, start_lnum, end_lnum, new_lines) + new_lines = restore_interpolations(new_lines, interpolation_placeholders) -- Preserve indentation in case the code block is indented restore_surrounding(new_lines, surrounding) vim.schedule_wrap(formatter_cb)(err, idx, region, input_lines, new_lines) diff --git a/tests/injected/nix_bash_injection.nix b/tests/injected/nix_bash_injection.nix new file mode 100644 index 00000000..692c40c1 --- /dev/null +++ b/tests/injected/nix_bash_injection.nix @@ -0,0 +1,7 @@ +{ pkgs }: +pkgs.writeShellApplication { + name = "hello"; + text = '' + echo "hello" + ''; +} diff --git a/tests/injected/nix_bash_injection.nix.formatted b/tests/injected/nix_bash_injection.nix.formatted new file mode 100644 index 00000000..99a59611 --- /dev/null +++ b/tests/injected/nix_bash_injection.nix.formatted @@ -0,0 +1,7 @@ +{ pkgs }: +pkgs.writeShellApplication { + name = "hello"; + text = '' + >echo "hello"< + ''; +} diff --git a/tests/injected/nix_bash_injection_with_interpolation.nix b/tests/injected/nix_bash_injection_with_interpolation.nix new file mode 100644 index 00000000..fa7c5484 --- /dev/null +++ b/tests/injected/nix_bash_injection_with_interpolation.nix @@ -0,0 +1,10 @@ +{ pkgs }: +let + name = "world"; +in +pkgs.writeShellApplication { + name = "hello"; + text = '' + echo "hello ${name}" + ''; +} diff --git a/tests/injected/nix_bash_injection_with_interpolation.nix.formatted b/tests/injected/nix_bash_injection_with_interpolation.nix.formatted new file mode 100644 index 00000000..3e048bf5 --- /dev/null +++ b/tests/injected/nix_bash_injection_with_interpolation.nix.formatted @@ -0,0 +1,10 @@ +{ pkgs }: +let + name = "world"; +in +pkgs.writeShellApplication { + name = "hello"; + text = '' + >echo "hello ${name}"< + ''; +} diff --git a/tests/injected/nix_lua_injection.nix b/tests/injected/nix_lua_injection.nix new file mode 100644 index 00000000..a458d6fc --- /dev/null +++ b/tests/injected/nix_lua_injection.nix @@ -0,0 +1,9 @@ +let + y = "hello"; + x = # lua + '' + local foo = "bar" + local bar = "${y}" + ''; +in +x diff --git a/tests/injected/nix_lua_injection.nix.formatted b/tests/injected/nix_lua_injection.nix.formatted new file mode 100644 index 00000000..2ca9777b --- /dev/null +++ b/tests/injected/nix_lua_injection.nix.formatted @@ -0,0 +1,9 @@ +let + y = "hello"; + x = # lua + '' + >local foo = "bar" + local bar = "${y}"< + ''; +in +x diff --git a/tests/injected/rust_format_in_markdown.md b/tests/injected/rust_format_in_markdown.md new file mode 100644 index 00000000..d82bfc5d --- /dev/null +++ b/tests/injected/rust_format_in_markdown.md @@ -0,0 +1,5 @@ +```rust +fn main() { + println!("hello"); +} +``` diff --git a/tests/injected/rust_format_in_markdown.md.formatted b/tests/injected/rust_format_in_markdown.md.formatted new file mode 100644 index 00000000..796e024c --- /dev/null +++ b/tests/injected/rust_format_in_markdown.md.formatted @@ -0,0 +1,5 @@ +```rust +>fn main() { + println!("hello"); +}< +``` diff --git a/tests/injected/template_interpolation.ts b/tests/injected/template_interpolation.ts new file mode 100644 index 00000000..9ab33ca0 --- /dev/null +++ b/tests/injected/template_interpolation.ts @@ -0,0 +1,16 @@ +const name = "world"; + +foo.innerHTML = ` +
+ Hello ${name}! +
+`; + +bar.innerHTML = `
${name}
`; + +// Ensure nested braces within the interpolation don't break placeholder matching. +baz.innerHTML = ` +
+ ${fn({ a: 1, b: { c: 2 } })} +
+`; diff --git a/tests/injected/template_interpolation.ts.formatted b/tests/injected/template_interpolation.ts.formatted new file mode 100644 index 00000000..0ff621fb --- /dev/null +++ b/tests/injected/template_interpolation.ts.formatted @@ -0,0 +1,16 @@ +const name = "world"; + +foo.innerHTML = ` + >
+ Hello ${name}! +
< +`; + +bar.innerHTML = `>
${name}
<`; + +// Ensure nested braces within the interpolation don't break placeholder matching. +baz.innerHTML = ` + >
+ ${fn({ a: 1, b: { c: 2 } })} +
< +`; diff --git a/tests/injected/template_interpolation_multiline.ts b/tests/injected/template_interpolation_multiline.ts new file mode 100644 index 00000000..e7e0e1ec --- /dev/null +++ b/tests/injected/template_interpolation_multiline.ts @@ -0,0 +1,9 @@ +const name = "world"; + +// Interpolation split across lines should still be preserved. +foo.innerHTML = ` +
+ Hello ${name + }! +
+`; diff --git a/tests/injected/template_interpolation_multiline.ts.formatted b/tests/injected/template_interpolation_multiline.ts.formatted new file mode 100644 index 00000000..daf93982 --- /dev/null +++ b/tests/injected/template_interpolation_multiline.ts.formatted @@ -0,0 +1,9 @@ +const name = "world"; + +// Interpolation split across lines should still be preserved. +foo.innerHTML = ` + >
+ Hello ${name + }! +
< +`; diff --git a/tests/injected_integration/nix_stylua_injected_deep.nix b/tests/injected_integration/nix_stylua_injected_deep.nix new file mode 100644 index 00000000..7e0396a4 --- /dev/null +++ b/tests/injected_integration/nix_stylua_injected_deep.nix @@ -0,0 +1,25 @@ +{ pkgs }: +let + y = "hello"; +in +# lua +'' + local deep={a={b={c={d={e=1}}}}} + local function f(x)if x then return{ok=true,val=x} end end + local msg = "a:${y}:b:${y}" + local json = [[${ + builtins.toJSON { + a = 1; + b = { + c = 2; + }; + list = [ + 1 + 2 + 3 + ]; + } + }]] + local out=f(deep.a.b.c.d.e) + if out then out.val=out.val+1 end +'' diff --git a/tests/injected_integration/nix_stylua_injected_deep.nix.formatted b/tests/injected_integration/nix_stylua_injected_deep.nix.formatted new file mode 100644 index 00000000..2795c23d --- /dev/null +++ b/tests/injected_integration/nix_stylua_injected_deep.nix.formatted @@ -0,0 +1,31 @@ +{ pkgs }: +let + y = "hello"; +in +# lua +'' + local deep = { a = { b = { c = { d = { e = 1 } } } } } + local function f(x) + if x then + return { ok = true, val = x } + end + end + local msg = "a:${y}:b:${y}" + local json = [[${ + builtins.toJSON { + a = 1; + b = { + c = 2; + }; + list = [ + 1 + 2 + 3 + ]; + } + }]] + local out = f(deep.a.b.c.d.e) + if out then + out.val = out.val + 1 + end +'' diff --git a/tests/injected_integration/nix_stylua_injected_many_interpolations.nix b/tests/injected_integration/nix_stylua_injected_many_interpolations.nix new file mode 100644 index 00000000..a458c718 --- /dev/null +++ b/tests/injected_integration/nix_stylua_injected_many_interpolations.nix @@ -0,0 +1,18 @@ +{ pkgs }: +let + y = "hello"; + z = "world"; +in +# lua +'' + local msg = + "${y}:${z}:${y}:${z}" + local cmd = + -- sh + [[ + echo "${y}" + echo "${z}" + echo "${y}${z}" + ]] + local nested = { a = { b = { c = { d = { e = { f = 1 } } } } } } +'' diff --git a/tests/injected_integration/nix_stylua_injected_many_interpolations.nix.formatted b/tests/injected_integration/nix_stylua_injected_many_interpolations.nix.formatted new file mode 100644 index 00000000..60eb066c --- /dev/null +++ b/tests/injected_integration/nix_stylua_injected_many_interpolations.nix.formatted @@ -0,0 +1,18 @@ +{ pkgs }: +let + y = "hello"; + z = "world"; +in +# lua +'' + local msg = + "${y}:${z}:${y}:${z}" + local cmd = + -- sh + [[ + echo "${y}" + echo "${z}" + echo "${y}${z}" + ]] + local nested = { a = { b = { c = { d = { e = { f = 1 } } } } } } +'' diff --git a/tests/injected_integration_spec.lua b/tests/injected_integration_spec.lua new file mode 100644 index 00000000..d0b78266 --- /dev/null +++ b/tests/injected_integration_spec.lua @@ -0,0 +1,113 @@ +require("plenary.async").tests.add_to_env() +local conform = require("conform") +local injected = require("conform.formatters.injected") +local runner = require("conform.runner") +local test_util = require("tests.test_util") + +---@param dir string +---@return string[] +local function list_test_files(dir) + ---@diagnostic disable-next-line: param-type-mismatch + local fd = vim.loop.fs_opendir(dir, nil, 32) + ---@diagnostic disable-next-line: param-type-mismatch + local entries = vim.loop.fs_readdir(fd) + local ret = {} + while entries do + for _, entry in ipairs(entries) do + if entry.type == "file" and not vim.endswith(entry.name, ".formatted") then + table.insert(ret, entry.name) + end + end + ---@diagnostic disable-next-line: param-type-mismatch + entries = vim.loop.fs_readdir(fd) + end + ---@diagnostic disable-next-line: param-type-mismatch + vim.loop.fs_closedir(fd) + return ret +end + +describe("injected formatter", function() + before_each(function() + --require("conform.log").level = vim.log.levels.TRACE + conform.formatters_by_ft = { + lua = { "stylua" }, + nix = { "nixfmt" }, + sh = { "test_mark" }, + } + -- A test formatter that bookends lines with "><" so we can check what was passed in + + conform.formatters.test_mark = { + format = function(self, ctx, lines, callback) + lines = vim.deepcopy(lines) + -- Simulate formatters removing starting newline + while #lines > 0 and lines[1] == "" do + table.remove(lines, 1) + end + -- Simulate formatters removing trailing newline + while #lines > 0 and lines[#lines] == "" do + table.remove(lines) + end + + lines[1] = ">" .. lines[1] + lines[#lines] = lines[#lines] .. "<" + callback(nil, lines) + end, + } + -- conform.formatters.nixfmt = require("conform.formatters.nixfmt") + conform.formatters.stylua = require("conform.formatters.stylua") + -- conform.formatters.shfmt = require("conform.formatters.shfmt") + end) + + after_each(function() + test_util.reset_editor() + end) + + -- check if stylua and nixfmt are available, if not skip the tests since we can't run them + if + not conform.get_formatter_info("stylua", 0).available + or not conform.get_formatter_info("nixfmt", 0) + then + return + end + + if vim.fn.has("nvim-0.11") == 0 then + -- We need treesitter from 0.11 or newer for the injected formatter + return + end + for _, filename in ipairs(list_test_files("tests/injected_integration")) do + local filepath = "./tests/injected_integration/" .. filename + local formatted_file = filepath .. ".formatted" + it(filename, function() + local bufnr = vim.fn.bufadd(filepath) + vim.fn.bufload(bufnr) + local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, true) + local config = assert(conform.get_formatter_config("injected", bufnr)) + + local ctx = runner.build_context(bufnr, config) + local err, done, new_lines + injected.format(injected, ctx, lines, function(e, formatted) + err = e + done = true + vim.api.nvim_buf_set_lines(bufnr, 0, -1, true, formatted) + conform.format({ + bufnr = bufnr, + async = false, + formatter = { "nixfmt" }, + lines = formatted, + }, function(ea, donea) + e = ea + new_lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, true) + done = donea + end) + end) + vim.wait(2000, function() + return done + end) + assert(err == nil, err) + local expected_bufnr = vim.fn.bufadd(formatted_file) + vim.fn.bufload(expected_bufnr) + local expected_lines = vim.api.nvim_buf_get_lines(expected_bufnr, 0, -1, true) + assert.are.same(expected_lines, new_lines) + end) + end +end) diff --git a/tests/injected_spec.lua b/tests/injected_spec.lua index 85390462..0b3d5994 100644 --- a/tests/injected_spec.lua +++ b/tests/injected_spec.lua @@ -32,6 +32,9 @@ describe("injected formatter", function() conform.formatters_by_ft = { lua = { "test_mark" }, html = { "test_mark" }, + rust = { "test_mark" }, + bash = { "test_mark" }, + sh = { "test_mark" }, } -- A test formatter that bookends lines with "><" so we can check what was passed in conform.formatters.test_mark = { diff --git a/tests/minimal_init.lua b/tests/minimal_init.lua index e10efc6f..51938792 100644 --- a/tests/minimal_init.lua +++ b/tests/minimal_init.lua @@ -1,11 +1,21 @@ vim.cmd([[set runtimepath+=.]]) +-- Allow `require("tests.*")` from this repo root. +do + local cwd = vim.fn.getcwd() + package.path = table.concat({ + package.path, + cwd .. "/?.lua", + cwd .. "/?/init.lua", + }, ";") +end + vim.o.swapfile = false vim.bo.swapfile = false require("tests.test_util").reset_editor() local ts = require("nvim-treesitter") -ts.install({ "markdown", "markdown_inline", "lua", "typescript", "html" }):wait(30000) +ts.install({ "markdown", "markdown_inline", "lua", "typescript", "html", "nix", "rust", "bash" }):wait(30000) vim.api.nvim_create_user_command("RunTests", function(opts) local path = opts.fargs[1] or "tests" diff --git a/tests/runner_spec.lua b/tests/runner_spec.lua index 507be488..986b5a21 100644 --- a/tests/runner_spec.lua +++ b/tests/runner_spec.lua @@ -454,20 +454,12 @@ print("a") end) it("can run the format command in the shell", function() - -- Mac echo doesn't seem to support -e, but the linux ci runner apparently doesn't have seq - if fs.is_mac then - conform.formatters.test = { - command = "seq", - args = "3 1 | sort", - } - run_formatter_test("", "1\n2\n3") - else - conform.formatters.test = { - command = "echo", - args = '-e "World\nHello" | sort', - } - run_formatter_test("", "Hello\nWorld") - end + -- Prefer printf for portability across mac/linux. + conform.formatters.test = { + command = "printf", + args = '"3\\n2\\n1\\n" | sort', + } + run_formatter_test("", "1\n2\n3") end) end) end)