diff --git a/README.md b/README.md index acf959a..3e81624 100644 --- a/README.md +++ b/README.md @@ -266,6 +266,7 @@ Builtin actions are all higher-order functions so they can easily have options o | if block/postfix | | ✅ | | | | | | | | | | `toggle_hash_style()` | | ✅ | | | | | | | | | | `conceal_string()` | | | ✅ | | | | | | ✅ | | +| `cycle_import()` | | | | | ✅ | | | | | | ## Testing To run the test suite, clone the repo and run `./run_spec`. It should pull all dependencies into `spec/support/` on diff --git a/lua/ts-node-action/actions/toggle_multiline.lua b/lua/ts-node-action/actions/toggle_multiline.lua index 47f45c0..3dd8ab9 100644 --- a/lua/ts-node-action/actions/toggle_multiline.lua +++ b/lua/ts-node-action/actions/toggle_multiline.lua @@ -4,6 +4,7 @@ local helpers = require("ts-node-action.helpers") ---@param uncollapsible table Used to specify "base" types that shouldn't be collapsed further. ---@return function local function collapse_child_nodes(padding, uncollapsible) + uncollapsible = uncollapsible or {} local function can_be_collapsed(child) return child:named_child_count() > 0 and not uncollapsible[child:type()] end diff --git a/lua/ts-node-action/filetypes/python.lua b/lua/ts-node-action/filetypes/python.lua index 53e1c81..ed4329b 100644 --- a/lua/ts-node-action/filetypes/python.lua +++ b/lua/ts-node-action/filetypes/python.lua @@ -1,5 +1,8 @@ -local helpers = require("ts-node-action.helpers") -local actions = require("ts-node-action.actions") +local actions = require("ts-node-action.actions") +local helpers = require("ts-node-action.helpers") +local nu = require("ts-node-action.filetypes.python.node_utils") +local conditional = require("ts-node-action.filetypes.python.conditional") +local cycle_import = require("ts-node-action.filetypes.python.cycle_import") -- Special cases: -- Because "is" and "not" are valid by themselves, they are seen as separate @@ -50,424 +53,30 @@ local padding = { ["from"] = "%s ", } +local uncollapsible = {} + local boolean_override = { ["True"] = "False", ["False"] = "True", } ---- @param node TSNode -local function node_trim_whitespace(node) - local start_row, _, end_row, _ = node:range() - vim.cmd("silent! keeppatterns " .. (start_row + 1) .. "," .. (end_row + 1) .. "s/\\s\\+$//g") -end - --- When inlined, these nodes must be parenthesized to avoid changing the --- meaning of the code and to avoid syntax errors. --- eg: x = lambda y: y + 1 if y else 0 --- x = (lambda y: y + 1) if y else 0 --- Both are valid, but the first is not equivalent to the second. --- Unlike the second, the first can not be expanded to: --- if y: --- x = lambda y: y + 1 --- else: --- x = 0 --- because "1 if y else 0" is inside the lambda. -local node_types_to_parenthesize = { - ["conditional_expression"] = true, - ["boolean_operator"] = true, - ["lambda"] = true, -} - -local function parenthesize_if_needed(node, text) - if node_types_to_parenthesize[node:type()] and text:sub(1, 1) ~= "(" then - return "(" .. text .. ")" - end - - return text -end - --- Recreating actions.toggle_multiline.collapse_child_nodes() here because --- it is not exported. It was not possible to use helpers.node_text() on a --- multiline node because it will include the "\n", which is invalid for the --- replacement text. --- ---- @param padding_override table ---- @return function -local function collapse_child_nodes(padding_override) - - --- @param node TSNode - --- @return string - local function action(node) - if not helpers.node_is_multiline(node) then - return helpers.node_text(node) - end - - local tbl = actions.toggle_multiline(padding_override) - local replacement = tbl[1][1](node) - - return replacement - end - - return action -end - --- Helper that returns the text of the left and right hand sides of a --- statement. For example, the left hand side of: --- --- - `return 1` is `return` and the right hand side is `1`. --- - `x = 1` is `x = ` and the right hand side is `1`. --- - `x = y = z = 1` is `x = y = z = ` and the right hand side is `1`. --- - `print(3)` is "" and the right hand side is `print(3)`. --- ---- @param node TSNode ---- @return string|nil, string|nil, string -local function node_text_lhs_rhs(node, padding_override) - local lhs = nil - local rhs = nil - local type = node:type() - local child = node:named_child(0) - local collapse = collapse_child_nodes(padding_override) - - if type == "return_statement" then - lhs = "return " - rhs = collapse(child) - elseif type == "expression_statement" then - type = child:type() - lhs = "" - - if type == "assignment" then - local identifiers = {} - -- handle multiple assignments, eg: x = y = z = 1 - while child:type() == "assignment" do - table.insert(identifiers, helpers.node_text(child:named_child(0))) - child = child:named_child(1) - end - lhs = table.concat(identifiers, " = ") .. " = " - rhs = collapse(child) - elseif type == "call" then - local identifier = helpers.node_text(child:named_child(0)) - child = child:named_child(1) - rhs = identifier .. collapse(child) - elseif type == "boolean_operator" or - type == "parenthesized_expression" then - rhs = collapse(child) - end - - end - - return lhs, rhs, type, child -end - --- The if/conditional_expression that we are expanding can find itself on --- the same row as an inlined for or if statement. --- For example: --- --- `for x in range(10): x = 1 if x > 5 else x + 1` --- `if x > 0: x = 1 if x > 5 else x + 1` --- --- Contrived, hopefully, but this handles it, by detecting if there is a --- for/if statement on the same row as our current parent. --- ---- @param parent TSNode ---- @param parent_type string ---- @param start_row number ---- @return TSNode, string ---- @return nil -local function find_row_parent(parent, parent_type, start_row) - - while parent ~= nil and - parent_type ~= "if_statement" and - parent_type ~= "for_statement" do - parent = parent:parent() - if parent == nil then - return nil - end - parent_type = parent:type() - if select(1, parent:start()) ~= start_row then - return nil - end - end - - if parent_type == "if_statement" or parent_type == "for_statement" then - return parent, parent_type - end - - return nil -end - --- We detect if it's safe to expand an inline if/else surrounded by parens --- and remove them by skipping to it's parent, because the parent is --- replaced by this action, with the expanded if/else. --- --- Cases considered safe: --- `x = (conditional_expression)` --- `return (conditional_expression)` --- ---- @param parent TSNode ---- @param parent_type string ---- @return TSNode, string -local function skip_parens_by_reparenting(parent, parent_type) - if parent_type == "parenthesized_expression" then - local paren_parent = parent:parent() - local paren_parent_type = paren_parent:type() - if paren_parent_type == "assignment" or - paren_parent_type == "return_statement" then - parent = paren_parent - parent_type = paren_parent_type - end - end - return parent, parent_type -end - ---- @param node TSNode ---- @param comments table ---- @return nil (mutates comments) -local function deep_collect_comments(node, comments) - for child in node:iter_children() do - if child:named() then - if child:type() == "comment" then - table.insert(comments, child) - else - deep_collect_comments(child, comments) - end - end - end -end - ---- @param parent TSNode ---- @param children table ---- @param comments table ---- @return nil (mutates children and comments) -local function collect_named_children(parent, children, comments) - for child in parent:iter_children() do - if child:named() then - if child:type() == "comment" then - table.insert(comments, child) - else - table.insert(children, child) - deep_collect_comments(child, comments) - end - end - end -end - ---- @param if_statement TSNode ---- @return table -local function destructure_if_statement(if_statement) - local condition - local consequence = {} - local alternative = {} - local comments = {} - - for child in if_statement:iter_children() do - if child:named() then - local child_type = child:type() - - if child_type == "comment" then - table.insert(comments, child) - elseif child_type == "block" then - collect_named_children(child, consequence, comments) - elseif child_type == "else_clause" then - local block = {} - collect_named_children(child, block, comments) - collect_named_children(block[1], alternative, comments) - else - condition = child - end - - end - end - - return { - node = if_statement, - condition = condition, - consequence = consequence, - alternative = alternative, - comments = comments - } -end - ---- @param node TSNode ---- @return table -local function destructure_conditional_expression(node) - local comments = {} - local children = {} - - collect_named_children(node, children, comments) - - return { - node = node, - condition = children[2], - consequence = { children[1] }, -- as a table for consistency - alternative = { children[3] }, -- which allows for sharing - comments = comments, - } -end - ---- @param stmt table ---- @return string, table, TSNode ---- @return nil -local function expand_cond_expr(stmt, padding_override) - local parent = stmt.node:parent() - local parent_type = parent:type() - - parent, parent_type = skip_parens_by_reparenting(parent, parent_type) - - local lhs - if parent_type == "return_statement" then - lhs = "return " - elseif parent_type == "assignment" then - local identifiers = {} - -- handle multiple assignments, eg: x = y = z = 1 - while parent:type() == "assignment" do - table.insert(identifiers, 1, helpers.node_text(parent:named_child(0))) - parent = parent:parent() - end - lhs = table.concat(identifiers, " = ") .. " = " - elseif parent_type == "expression_statement" then - lhs = "" - elseif parent_type == "block" or parent_type == "module" then - lhs = "" - parent = stmt.node - else - -- parent context is not yet supported, eg: y = 3 or (4 if x > 0 else 5) - return - end - - local start_row, start_col = parent:start() - local row_parent = find_row_parent(parent, parent_type, start_row) - local cursor = {} - -- when we are embedded on the end of an inlined if/for statement, we need - -- to expand on to the next line and shift the cursor/indent - local if_indent = "" - local else_indent = "" - if row_parent then - local _, row_start_col = row_parent:start() - -- cursor position is relative to the node being replaced (parent) - cursor = { row = 1, col = row_start_col - start_col + 4 } - if_indent = string.rep(" ", row_start_col + 4) - else_indent = if_indent - else - else_indent = string.rep(" ", start_col) - end - local body_indent = else_indent .. string.rep(" ", 4) - - local collapse = collapse_child_nodes(padding_override) - local replacement = { - if_indent .. "if " .. collapse(stmt.condition) .. ":", - body_indent .. lhs .. collapse(stmt.consequence[1]), - } - - if #stmt.alternative > 0 then - table.insert(replacement, else_indent .. "else:") - table.insert( - replacement, - body_indent .. lhs .. collapse(stmt.alternative[1]) - ) - end - - local callback = nil - if row_parent then - table.insert(replacement, 1, "") - callback = function() node_trim_whitespace(parent) end - end - - return replacement, { - cursor = cursor, - callback = callback, - format = true, - target = parent, - } -end - ---- @param stmt table { node, condition, consequence, alternative, comments } ---- @param padding_override table ---- @return string, table, TSNode ---- @return nil -local function inline_if(stmt, padding_override) - - local lhs, rhs, _, child = node_text_lhs_rhs( - stmt.consequence[1], - padding_override +---@param padding_override table +---@param uncollapsible_override table +---@return table +local function inline_if_statement(padding_override, uncollapsible_override) + padding_override = vim.tbl_deep_extend( + 'force', padding, padding_override or {} ) - if lhs == nil then - return - end - rhs = parenthesize_if_needed(child, rhs) - - local cond_text = collapse_child_nodes(padding_override)(stmt.condition) - - local replacement = { "if " .. cond_text .. ": " .. lhs .. rhs } - return replacement, { cursor = {} } -end - ---- @param cons_type string ---- @param alt_type string ---- @param cons_lhs string ---- @param alt_lhs string ---- @return boolean -local function body_types_are_inlineable(cons_type, alt_type, cons_lhs, alt_lhs) - -- strict match - if cons_type == "assignment" or alt_type == "assignment" then - return cons_type == alt_type and cons_lhs == alt_lhs - elseif cons_type == "return_statement" or alt_type == "return_statement" then - return cons_type == alt_type - end - -- these do not depend on a common lhs and can freely appear on either side - local mixable_match_body_types = { - ["call"] = true, - ["boolean_operator"] = true, - ["parenthesized_expression"] = true, - } - return mixable_match_body_types[cons_type] and - mixable_match_body_types[alt_type] -end - ---- @param stmt table { node, condition, consequence, alternative, comments } ---- @param padding_override table ---- @return string, table, TSNode ---- @return nil -local function inline_ifelse(stmt, padding_override) - - local cons_lhs, cons_rhs, cons_type, cons_child = node_text_lhs_rhs( - stmt.consequence[1], - padding_override + uncollapsible_override = vim.tbl_deep_extend( + 'force', uncollapsible, uncollapsible_override or {} ) - if cons_lhs == nil then - return - end - cons_rhs = parenthesize_if_needed(cons_child, cons_rhs) - - local alt_lhs, alt_rhs, alt_type, alt_child = node_text_lhs_rhs( - stmt.alternative[1], - padding_override + local collapse = nu.collapse_func( + padding_override, + uncollapsible_override ) - if alt_rhs == nil or not body_types_are_inlineable(cons_type, alt_type, cons_lhs, alt_lhs) then - return - end - alt_rhs = parenthesize_if_needed(alt_child, alt_rhs) - - local cond_text = collapse_child_nodes(padding_override)(stmt.condition) - - local replacement = cons_lhs .. cons_rhs .. - " if " .. cond_text .. - " else " .. alt_rhs - - return replacement, { - cursor = { col = string.len(cons_lhs .. cons_rhs) + 1 }, - } -end - ---- @param padding_override table ---- @return table -local function inline_if_statement(padding_override) - padding_override = padding_override or padding - - --- @param if_statement TSNode - --- @return string, table, TSNode local function action(if_statement) - local stmt = destructure_if_statement(if_statement) + local stmt = conditional.destructure_if_statement(if_statement) -- we can't inline multiple statements within a block if #stmt.consequence > 1 or #stmt.alternative > 1 then return @@ -480,15 +89,15 @@ local function inline_if_statement(padding_override) if helpers.node_is_multiline(if_statement) then local fn if #stmt.alternative ~= 0 then - fn = inline_ifelse + fn = conditional.inline_ifelse else - fn = inline_if + fn = conditional.inline_if end - return fn(stmt, padding_override) + return fn(stmt, collapse) else -- an if_statement of the form `if True: print(1)` -- and this knows how to expand it - return expand_cond_expr(stmt, padding_override) + return conditional.expand_cond_expr(stmt, collapse) end end @@ -496,38 +105,75 @@ local function inline_if_statement(padding_override) return { action, name = "Inline Conditional" } end ---- @param padding_override table ---- @return table|nil -local function expand_conditional_expression(padding_override) - padding_override = padding_override or padding +---@param padding_override table +---@param uncollapsible_override table +---@return table +local function expand_conditional_expression( + padding_override, uncollapsible_override +) + padding_override = vim.tbl_deep_extend( + 'force', padding, padding_override or {} + ) + uncollapsible_override = vim.tbl_deep_extend( + 'force', uncollapsible, uncollapsible_override or {} + ) + local collapse = nu.collapse_func( + padding_override, + uncollapsible_override + ) - --- @param conditional_expression TSNode - --- @return string, table, TSNode local function action(conditional_expression) - local stmt = destructure_conditional_expression(conditional_expression) + local stmt = conditional.destructure_conditional_expression( + conditional_expression + ) if #stmt.comments > 0 then return end - return expand_cond_expr(stmt, padding_override) + return conditional.expand_cond_expr(stmt, collapse) end return { action, name = "Expand Conditional" } end +-- see python/cycle_import.lua for more config options +local cycle_import_from_config = { + ---@type string[] list of formats to cycle through; uses the provided order + formats = { "single", "inline", "expand" }, + ---@type number maximum line length for inline imports + line_length = 80, + ---@type boolean include siblings when format differs + siblings_of_any_format = true, + ---@type boolean use parens for inline imports (otherwise use \) + inline_use_parens = true, + ---@type boolean use parens for expanded imports (otherwise use \) + expand_use_parens = true, +} + +local cycle_import_config = { + ---@type string[] list of formats to cycle through; uses the provided order + formats = { "single", "inline" }, + ---@type number maximum line length for inline imports + line_length = 80, + ---@type boolean include siblings when format differs + siblings_of_any_format = true, +} + return { - ["dictionary"] = actions.toggle_multiline(padding), - ["set"] = actions.toggle_multiline(padding), - ["list"] = actions.toggle_multiline(padding), - ["tuple"] = actions.toggle_multiline(padding), - ["argument_list"] = actions.toggle_multiline(padding), - ["parameters"] = actions.toggle_multiline(padding), - ["list_comprehension"] = actions.toggle_multiline(padding), - ["set_comprehension"] = actions.toggle_multiline(padding), - ["dictionary_comprehension"] = actions.toggle_multiline(padding), - ["generator_expression"] = actions.toggle_multiline(padding), + ["dictionary"] = actions.toggle_multiline(padding, uncollapsible), + ["set"] = actions.toggle_multiline(padding, uncollapsible), + ["list"] = actions.toggle_multiline(padding, uncollapsible), + ["tuple"] = actions.toggle_multiline(padding, uncollapsible), + ["argument_list"] = actions.toggle_multiline(padding, uncollapsible), + ["parameters"] = actions.toggle_multiline(padding, uncollapsible), + ["list_comprehension"] = actions.toggle_multiline(padding, uncollapsible), + ["set_comprehension"] = actions.toggle_multiline(padding, uncollapsible), + ["dictionary_comprehension"] = actions.toggle_multiline(padding, uncollapsible), + ["generator_expression"] = actions.toggle_multiline(padding, uncollapsible), ["true"] = actions.toggle_boolean(boolean_override), ["false"] = actions.toggle_boolean(boolean_override), ["comparison_operator"] = actions.toggle_operator(), ["integer"] = actions.toggle_int_readability(), - ["conditional_expression"] = { expand_conditional_expression(padding), }, - ["if_statement"] = { inline_if_statement(padding), }, + ["conditional_expression"] = { expand_conditional_expression(padding, uncollapsible), }, + ["if_statement"] = { inline_if_statement(padding, uncollapsible), }, + ["import_from_statement"] = { cycle_import(cycle_import_from_config), }, + ["import_statement"] = { cycle_import(cycle_import_config), }, } diff --git a/lua/ts-node-action/filetypes/python/conditional.lua b/lua/ts-node-action/filetypes/python/conditional.lua new file mode 100644 index 0000000..92bd789 --- /dev/null +++ b/lua/ts-node-action/filetypes/python/conditional.lua @@ -0,0 +1,366 @@ +local helpers = require("ts-node-action.helpers") +local nu = require("ts-node-action.filetypes.python.node_utils") + +local M = {} + +-- When inlined, these nodes must be parenthesized to avoid changing the +-- meaning of the code and to avoid syntax errors. +-- eg: x = lambda y: y + 1 if y else 0 +-- x = (lambda y: y + 1) if y else 0 +-- Both are valid, but the first is not equivalent to the second. +-- Unlike the second, the first can not be expanded to: +-- if y: +-- x = lambda y: y + 1 +-- else: +-- x = 0 +-- because "1 if y else 0" is inside the lambda. +local node_types_to_parenthesize = { + ["conditional_expression"] = true, + ["boolean_operator"] = true, + ["lambda"] = true, +} + +local function parenthesize_if_needed(node, text) + if node_types_to_parenthesize[node:type()] and text:sub(1, 1) ~= "(" then + return "(" .. text .. ")" + end + + return text +end + +-- Helper that returns the text of the left and right hand sides of a +-- statement. For example, the left hand side of: +-- +-- - `return 1` is `return` and the right hand side is `1`. +-- - `x = 1` is `x = ` and the right hand side is `1`. +-- - `x = y = z = 1` is `x = y = z = ` and the right hand side is `1`. +-- - `print(3)` is "" and the right hand side is `print(3)`. +-- +---@param node TSNode +---@param collapse function +---@return string|nil, string|nil, string|nil, TSNode|nil +local function node_text_lhs_rhs(node, collapse) + local lhs = nil + local rhs = nil + local type = node:type() + local child = node:named_child(0) + + if type == "return_statement" then + lhs = "return " + rhs = collapse(child) + elseif type == "expression_statement" then + type = child:type() + lhs = "" + + if type == "assignment" then + local identifiers = {} + -- handle multiple assignments, eg: x = y = z = 1 + while child:type() == "assignment" do + table.insert(identifiers, helpers.node_text(child:named_child(0))) + child = child:named_child(1) + end + lhs = table.concat(identifiers, " = ") .. " = " + rhs = collapse(child) + elseif type == "call" then + local identifier = helpers.node_text(child:named_child(0)) + child = child:named_child(1) + rhs = identifier .. collapse(child) + elseif type == "boolean_operator" or + type == "parenthesized_expression" then + rhs = collapse(child) + end + + end + + return lhs, rhs, type, child +end + +-- The if/conditional_expression that we are expanding can find itself on +-- the same row as an inlined for or if statement. +-- For example: +-- +-- `for x in range(10): x = 1 if x > 5 else x + 1` +-- `if x > 0: x = 1 if x > 5 else x + 1` +-- +-- Contrived, hopefully, but this handles it, by detecting if there is a +-- for/if statement on the same row as our current parent. +-- +---@param parent TSNode +---@param parent_type string +---@param start_row number +---@return TSNode|nil, string|nil +local function find_row_parent(parent, parent_type, start_row) + + while parent ~= nil and + parent_type ~= "if_statement" and + parent_type ~= "for_statement" do + parent = parent:parent() + if parent == nil then + return + end + parent_type = parent:type() + if select(1, parent:start()) ~= start_row then + return + end + end + + if parent_type == "if_statement" or parent_type == "for_statement" then + return parent, parent_type + end +end + +-- We detect if it's safe to expand an inline if/else surrounded by parens +-- and remove them by skipping to it's parent, because the parent is +-- replaced by this action, with the expanded if/else. +-- +-- Cases considered safe: +-- `x = (conditional_expression)` +-- `return (conditional_expression)` +-- +---@param parent TSNode +---@param parent_type string +---@return TSNode, string +local function skip_parens_by_reparenting(parent, parent_type) + if parent_type == "parenthesized_expression" then + local paren_parent = parent:parent() + local paren_parent_type = paren_parent:type() + if paren_parent_type == "assignment" or + paren_parent_type == "return_statement" then + parent = paren_parent + parent_type = paren_parent_type + end + end + return parent, parent_type +end + +---@param node TSNode +---@param comments table +---@return nil (mutates comments) +local function deep_collect_comments(node, comments) + for child in nu.iter_named_children(node) do + if child:type() == "comment" then + table.insert(comments, child) + else + deep_collect_comments(child, comments) + end + end +end + +---@param parent TSNode +---@param children table +---@param comments table +---@return nil (mutates children and comments) +local function collect_named_children(parent, children, comments) + for child in nu.iter_named_children(parent) do + if child:type() == "comment" then + table.insert(comments, child) + else + table.insert(children, child) + deep_collect_comments(child, comments) + end + end +end + +---@param if_statement TSNode +---@return table +M.destructure_if_statement = function(if_statement) + local condition + local consequence = {} + local alternative = {} + local comments = {} + + for child in nu.iter_named_children(if_statement) do + local child_type = child:type() + + if child_type == "comment" then + table.insert(comments, child) + elseif child_type == "block" then + collect_named_children(child, consequence, comments) + elseif child_type == "else_clause" then + local block = {} + collect_named_children(child, block, comments) + collect_named_children(block[1], alternative, comments) + else + condition = child + end + end + + return { + node = if_statement, + condition = condition, + consequence = consequence, + alternative = alternative, + comments = comments + } +end + +---@param node TSNode +---@return table +M.destructure_conditional_expression = function(node) + local comments = {} + local children = {} + + collect_named_children(node, children, comments) + + return { + node = node, + condition = children[2], + consequence = { children[1] }, -- as a table for consistency + alternative = { children[3] }, -- which allows for sharing + comments = comments, + } +end + +---@param stmt table +---@param collapse function +---@return table|nil, table|nil +M.expand_cond_expr = function(stmt, collapse) + local parent = stmt.node:parent() + local parent_type = parent:type() + + parent, parent_type = skip_parens_by_reparenting(parent, parent_type) + + local lhs + if parent_type == "return_statement" then + lhs = "return " + elseif parent_type == "assignment" then + local identifiers = {} + -- handle multiple assignments, eg: x = y = z = 1 + while parent:type() == "assignment" do + table.insert(identifiers, 1, helpers.node_text(parent:named_child(0))) + parent = parent:parent() + end + lhs = table.concat(identifiers, " = ") .. " = " + elseif parent_type == "expression_statement" then + lhs = "" + elseif parent_type == "block" or parent_type == "module" then + lhs = "" + parent = stmt.node + else + -- parent context is not yet supported, eg: y = 3 or (4 if x > 0 else 5) + return + end + + local start_row, start_col = parent:start() + local row_parent = find_row_parent(parent, parent_type, start_row) + local cursor = {} + -- when we are embedded on the end of an inlined if/for statement, we need + -- to expand on to the next line and shift the cursor/indent + local if_indent = "" + local else_indent = "" + if row_parent then + local _, row_start_col = row_parent:start() + -- cursor position is relative to the node being replaced (parent) + cursor = { row = 1, col = row_start_col - start_col + 4 } + if_indent = string.rep(" ", row_start_col + 4) + else_indent = if_indent + else + else_indent = string.rep(" ", start_col) + end + local body_indent = else_indent .. string.rep(" ", 4) + + local replacement = { + if_indent .. "if " .. collapse(stmt.condition) .. ":", + body_indent .. lhs .. collapse(stmt.consequence[1]), + } + + if #stmt.alternative > 0 then + table.insert(replacement, else_indent .. "else:") + table.insert( + replacement, + body_indent .. lhs .. collapse(stmt.alternative[1]) + ) + end + + local callback = nil + if row_parent then + table.insert(replacement, 1, "") + callback = function() nu.trim_whitespace(parent) end + end + + return replacement, { + cursor = cursor, + callback = callback, + format = true, + target = parent, + } +end + +--- @param stmt table { node, condition, consequence, alternative, comments } +--- @param collapse function +--- @return table|nil, table|nil +M.inline_if = function(stmt, collapse) + + local lhs, rhs, _, child = node_text_lhs_rhs( + stmt.consequence[1], + collapse + ) + if lhs == nil then + return + end + rhs = parenthesize_if_needed(child, rhs) + + local cond_text = collapse(stmt.condition) + + local replacement = { "if " .. cond_text .. ": " .. lhs .. rhs } + return replacement, { cursor = {} } +end + +---@param cons_type string +---@param alt_type string +---@param cons_lhs string +---@param alt_lhs string +---@return boolean +local function body_types_are_inlineable(cons_type, alt_type, cons_lhs, alt_lhs) + -- strict match + if cons_type == "assignment" or alt_type == "assignment" then + return cons_type == alt_type and cons_lhs == alt_lhs + elseif cons_type == "return_statement" or alt_type == "return_statement" then + return cons_type == alt_type + end + -- these do not depend on a common lhs and can freely appear on either side + local mixable_match_body_types = { + ["call"] = true, + ["boolean_operator"] = true, + ["parenthesized_expression"] = true, + } + return mixable_match_body_types[cons_type] and + mixable_match_body_types[alt_type] +end + +---@param stmt table { node, condition, consequence, alternative, comments } +---@param collapse function +---@return string|nil, table|nil +M.inline_ifelse = function(stmt, collapse) + + local cons_lhs, cons_rhs, cons_type, cons_child = node_text_lhs_rhs( + stmt.consequence[1], + collapse + ) + if cons_lhs == nil then + return + end + cons_rhs = parenthesize_if_needed(cons_child, cons_rhs) + + local alt_lhs, alt_rhs, alt_type, alt_child = node_text_lhs_rhs( + stmt.alternative[1], + collapse + ) + if alt_rhs == nil or not body_types_are_inlineable(cons_type, alt_type, cons_lhs, alt_lhs) then + return + end + + alt_rhs = parenthesize_if_needed(alt_child, alt_rhs) + + local cond_text = collapse(stmt.condition) + + local replacement = cons_lhs .. cons_rhs .. + " if " .. cond_text .. + " else " .. alt_rhs + + return replacement, { + cursor = { col = string.len(cons_lhs .. cons_rhs) + 1 }, + } +end + +return M diff --git a/lua/ts-node-action/filetypes/python/cycle_import.lua b/lua/ts-node-action/filetypes/python/cycle_import.lua new file mode 100644 index 0000000..f00c89b --- /dev/null +++ b/lua/ts-node-action/filetypes/python/cycle_import.lua @@ -0,0 +1,339 @@ +local helpers = require("ts-node-action.helpers") +local nu = require("ts-node-action.filetypes.python.node_utils") + +local function print_error(...) + print("TS:NodeAction:Python:CycleImport - ", ...) +end + +---@param import_from_statement TSNode +---@return table +local function destructure_import_from_statement(import_from_statement) + local module = import_from_statement:named_child(0) + local names = {} + local comments = {} + + local prev_sibling_row = module:start() + local siblings_share_line = false + + for sibling in nu.iter_next_named_sibling(module) do + if sibling:type() == "comment" then + table.insert(comments, sibling) + else + local sibling_start_row = sibling:start() + if sibling_start_row == prev_sibling_row then + siblings_share_line = true + end + prev_sibling_row = sibling_start_row + + table.insert(names, helpers.node_text(sibling)) + end + end + + local format = "single" + if #names > 1 or helpers.node_is_multiline(import_from_statement) then + if siblings_share_line then + format = "inline" + else + format = "expand" + local num_children = import_from_statement:child_count() + local last_child = import_from_statement:child(num_children - 1) + -- multiline inline ends with a paren sharing the same line + -- but if not using parens, then it's ambiguous + if not last_child:named() and helpers.node_text(last_child) == ")" then + local last_named_child = last_child:prev_named_sibling() + if last_child:start() == last_named_child:start() then + format = "inline" + end + end + end + end + + return { + type = "import_from_statement", + node = import_from_statement, + modules = { helpers.node_text(module) }, + names = names, + comments = comments, + format = format + } +end + +---@param import_statement TSNode +---@return table +local function destructure_import_statement(import_statement) + local modules = {} + local comments = {} + + for node in nu.iter_named_children(import_statement) do + if node:type() == "comment" then + table.insert(comments, node) + else + table.insert(modules, helpers.node_text(node)) + end + end + + return { + type = "import_statement", + node = import_statement, + modules = modules, + names = modules, + comments = comments, + format = #modules > 1 and "inline" or "single", + } +end + +-- Collect qualifying siblings adjacent to origin_stmt and destructure them. +-- +---@param origin_stmt table +---@param prev_siblings TSNode[] in reverse order +---@param next_siblings TSNode[] +---@param destructure fun(node: TSNode): table +---@param of_any_format boolean +---@return table[] +local function assemble_stmts( + origin_stmt, prev_siblings, next_siblings, destructure, of_any_format) + local stmts = {} + + for _, node in ipairs(prev_siblings) do + local sibling_stmt = destructure(node) + if of_any_format or sibling_stmt.format == origin_stmt.format then + table.insert(stmts, 1, sibling_stmt) + else + break + end + end + + table.insert(stmts, origin_stmt) + + for _, node in ipairs(next_siblings) do + local sibling_stmt = destructure(node) + if of_any_format or sibling_stmt.format == origin_stmt.format then + table.insert(stmts, sibling_stmt) + else + break + end + end + + return stmts +end + +local cycler_types = { + import_statement = { + allowed_formats = { "single", "inline", }, + destructure = destructure_import_statement, + make_sibling_validator = function(origin_stmt) + return function(sibling) + return sibling:type() == origin_stmt.type + end + end, + cycle = { + single = function(_, names, indent, _) + local replacement = {} + for i, name in ipairs(names) do + table.insert( + replacement, + (i ~= 1 and indent or "") .. "import " .. name + ) + end + return replacement + end, + inline = function(_, names, indent, config) + local replacement = {} + local prepend = "import " + local line = indent .. prepend .. table.concat(names, ", ") + + if #line > config.line_length then + line = indent .. prepend + for _, name in ipairs(names) do + if #line + #name >= config.line_length then + table.insert(replacement, line:sub(1, -3)) + line = indent .. prepend .. name .. ", " + else + line = line .. name .. ", " + end + end + line = line:sub(1, -3) + end + table.insert(replacement, line) + + return replacement + end, + }, + }, + import_from_statement = { + allowed_formats = { "single", "inline", "expand", }, + destructure = destructure_import_from_statement, + make_sibling_validator = function(origin_stmt) + local module = origin_stmt.modules[1] + return function(sibling) + return sibling:type() == origin_stmt.type and + helpers.node_text(sibling:named_child(0)) == module + end + end, + cycle = { + single = function(stmts, names, indent, _) + local replacement = {} + for i, name in ipairs(names) do + table.insert( + replacement, + (i == 1 and "" or indent) .. + "from " .. stmts[1].modules[1] .. " import " .. name .. "" + ) + end + return replacement + end, + inline = function(stmts, names, indent, config) + local replacement = {} + local prepend = "from " .. stmts[1].modules[1] .. " import " + local line = indent .. prepend .. table.concat(names, ", ") + local line_length = config.line_length + local use_parens = config.inline_use_parens + local eol_length = use_parens and 1 or 2 + + if #line > line_length then + + line = indent .. (use_parens and prepend .. "(" or prepend) + if #line + #names[1] > line_length then + table.insert(replacement, use_parens and line or (line .. "\\")) + line = indent .. " " + end + + for _, name in ipairs(names) do + if #line + #name + eol_length > line_length then + line = use_parens and line:sub(1, -2) or line .. "\\" + table.insert(replacement, line) + line = indent .. " " .. name .. ", " + else + line = line .. name .. ", " + end + end + + line = line:sub(1, -3) .. (use_parens and ")" or "") + end + table.insert(replacement, line) + return replacement + end, + expand = function(stmts, names, indent, config) + local replacement = {} + local use_parens = config.expand_use_parens + local first_eol = use_parens and "(" or "\\" + local body_eol = use_parens and "" or " \\" + table.insert( + replacement, + "from " .. stmts[1].modules[1] .. " import " .. first_eol + ) + for i, name in ipairs(names) do + local line + if i == #names then + line = indent .. " " .. name .. (use_parens and "," or "") + else + line = indent .. " " .. name .. "," .. body_eol + end + table.insert(replacement, line) + end + if use_parens then + table.insert(replacement, indent .. ")") + end + return replacement + end, + } + }, +} + +---@param formats table +---@param format string +---@return string|nil +local function find_next_format(formats, format) + for i, f in ipairs(formats) do + if f == format then + return formats[i + 1] or formats[1] + end + end +end + +---@param node TSNode +---@param config table +---@return table|nil, table|nil +local function cycle(node, config) + + local cycler = cycler_types[node:type()] + + local stmt = cycler.destructure(node) + if #stmt.comments > 0 then + return + end + + local format = find_next_format(config.formats, stmt.format) + if not format then + return + end + + if not vim.tbl_contains(cycler.allowed_formats, format) then + print_error("Format '" .. format .. "' not supported") + return + end + + local is_valid_sibling = cycler.make_sibling_validator(stmt) + local stmts = assemble_stmts( + stmt, + nu.takewhile(is_valid_sibling, nu.iter_prev_named_sibling(stmt.node)), + nu.takewhile(is_valid_sibling, nu.iter_next_named_sibling(stmt.node)), + cycler.destructure, + config.siblings_of_any_format + ) + local names = vim.tbl_flatten( + vim.tbl_map(function(a_stmt) return a_stmt["names"] end, stmts) + ) + + local start = {node:start()} + local indent = string.rep(" ", start[2]) + + local replacement = cycler.cycle[format](stmts, names, indent, config) + local target = nu.make_target( + stmts[1].node, + { stmts[1].node:start() }, + { stmts[#stmts].node:end_() } + ) + return replacement, { + target = target, + cursor = {row = 0, col = 0}, + format = true, + } +end + +local default_config = { + ---@type table[] formats to cycle through, in the order provided + formats = {}, + ---@type number maximum line length for inline imports + line_length = 80, + ---@type boolean include siblings when format differs + siblings_of_any_format = true, + ---@type boolean use parens for inline imports (otherwise use \) + inline_use_parens = true, + ---@type boolean use parens for expanded imports (otherwise use \) + expand_use_parens = true, +} + +---@param config table +---@return table|nil +return function(config) + config = vim.tbl_deep_extend('force', default_config, config or {}) + + vim.validate{ + formats={ config.formats, "table" }, + line_length={ config.line_length, "number" }, + siblings_of_any_format={ config.siblings_of_any_format, "boolean" }, + inline_use_parens={ config.inline_use_parens, "boolean" }, + expand_use_parens={ config.expand_use_parens, "boolean" }, + } + + if #config.formats == 0 then + print_error("Empty config.formats, no formats to cycle") + end + + local function action(node) + return cycle(node, config) + end + + return { action, name = "Cycle Import" } +end diff --git a/lua/ts-node-action/filetypes/python/node_utils.lua b/lua/ts-node-action/filetypes/python/node_utils.lua new file mode 100644 index 0000000..983087e --- /dev/null +++ b/lua/ts-node-action/filetypes/python/node_utils.lua @@ -0,0 +1,144 @@ +local actions = require("ts-node-action.actions") +local helpers = require("ts-node-action.helpers") + +-- WARN: Functions defined here should be treated as private/internal. +-- This is like an incubator and all are subject to change. + +-- NOTE: All functions are for TSNode, so rather than prefixing every function +-- name with "node_", the module is named "node_utils". + +local M = {} + +M.lines = function(node) + local lines = helpers.node_text(node) + if type(lines) == "string" then + return { lines } + end + return lines +end + +---@param node TSNode +M.trim_whitespace = function(node) + local start_row, _, end_row, _ = node:range() + vim.cmd("silent! keeppatterns " .. (start_row + 1) .. "," .. (end_row + 1) .. "s/\\s\\+$//g") +end + +-- Recreating actions.toggle_multiline.collapse_child_nodes() here because +-- it is not exported. +-- +---@param padding table +---@param uncollapsible table +---@return function @A function that takes a TSNode and returns a string +M.collapse_func = function(padding, uncollapsible) + local collapse = actions.toggle_multiline(padding, uncollapsible)[1][1] + + return function(node) + if not helpers.node_is_multiline(node) then + return helpers.node_text(node) + end + return collapse(node) + end +end + +-- Like vim.tbl_filter, but for TSNodes. +-- +---@param accept fun(node: TSNode): boolean @returns true for a valid node +---@param iter fun(): TSNode|nil @returns the next node +---@return TSNode[] +M.filter = function(accept, iter) + local nodes = {} + local node = iter() + while node and accept(node) do + table.insert(nodes, node) + node = iter() + end + return nodes +end + +-- Like filter, but stops at the first falsey value. +-- +---@param accept fun(node: TSNode): boolean @returns true for a valid node +---@param iter fun(): TSNode|nil @returns the next node +---@return TSNode[] +M.takewhile = function(accept, iter) + local nodes = {} + local node = iter() + while node and accept(node) do + table.insert(nodes, node) + node = iter() + end + return nodes +end + +M.iter_named_children = function(node) + local iter = node:iter_children() + return function() + local child = iter() + while child and not child:named() do + child = iter() + end + return child + end +end +M.iter_prev_named_sibling = function(node) + local sibling = node:prev_named_sibling() + return function() + if sibling then + local curr_sibling = sibling + sibling = sibling:prev_named_sibling() + return curr_sibling + end + end +end +M.iter_next_named_sibling = function(node) + local sibling = node:next_named_sibling() + return function() + if sibling then + local curr_sibling = sibling + sibling = sibling:next_named_sibling() + return curr_sibling + end + end +end +M.iter_parent = function(node) + local parent = node:parent() + return function() + if parent then + local curr_parent = parent + parent = parent:parent() + return curr_parent + end + end +end + + +-- Create a fake node to represent the replacement target. This is necessary +-- when the replacement spans multiple nodes without a suitable parent to serve +-- as a the target (eg, a top-level node's parent is the root and we are acting +-- on multiple children). +-- +-- This is indistiguishable from a TSNode, other than type(target) == "table". +-- +---@param node TSNode +---@param start_pos table +---@param end_pos table +---@return table +M.make_target = function(node, start_pos, end_pos) + -- TSNode's are userdata, which can't be cloned/altered, so this proxy's calls + -- to it and overrides the position methods. + local target = {} + for k, _ in pairs(getmetatable(node)) do + target[k] = function(_, ...) + return node[k](node, ...) + end + end + function target:start() return unpack(start_pos) end + function target:end_() return unpack(end_pos) end + function target:range() + return start_pos[1], start_pos[2], end_pos[1], end_pos[2] + end + + return target +end + +return M diff --git a/lua/ts-node-action/init.lua b/lua/ts-node-action/init.lua index b769f0a..7d1aeb8 100644 --- a/lua/ts-node-action/init.lua +++ b/lua/ts-node-action/init.lua @@ -1,3 +1,5 @@ +---@alias TSNode userdata + local M = {} --- @private diff --git a/spec/filetypes/python/import_from_statement_spec.lua b/spec/filetypes/python/import_from_statement_spec.lua new file mode 100644 index 0000000..53ad166 --- /dev/null +++ b/spec/filetypes/python/import_from_statement_spec.lua @@ -0,0 +1,349 @@ +dofile("./spec/spec_helper.lua") + +local Helper = SpecHelper.new("python", { shiftwidth = 4 }) + +describe("import_from_statement", function() + + + it("cycles from single to inline", function() + assert.are.same( + { + [[from foo import bar, baz, qux]], + }, + Helper:call({ + [[from foo import bar]], + [[from foo import baz]], + [[from foo import qux]], + }) + ) + end) + + it("cycles from inline to expand", function() + assert.are.same( + { + [[from foo import (]], + [[ bar,]], + [[ baz,]], + [[ qux,]], + [[)]], + }, + Helper:call({ + [[from foo import bar, baz, qux]], + }) + ) + end) + + it("cycles from expand to single", function() + assert.are.same( + { + [[from foo import bar]], + [[from foo import baz]], + [[from foo import qux]], + }, + Helper:call({ + [[from foo import (]], + [[ bar,]], + [[ baz,]], + [[ qux,]], + [[)]], + }) + ) + end) + + it("cycles from inline to expand (inline detect w continuation)", function() + assert.are.same( + { + [[from foo import (]], + [[ bar,]], + [[ baz,]], + [[)]], + }, + Helper:call({ + [[from foo import bar, \]], + [[ baz]], + }) + ) + end) + + it("cycles from inline to expand (inline detect w parens)", function() + assert.are.same( + { + [[from foo import (]], + [[ bar,]], + [[ baz,]], + [[)]], + }, + Helper:call({ + [[from foo import (bar,]], + [[ baz)]], + }) + ) + end) + + it("cycles from inline to expand (single, slightly ambiguous)", function() + assert.are.same( + { + [[from foo import (]], + [[ bar,]], + [[)]], + }, + Helper:call({ + [[from foo import (]], + [[ bar)]], + }) + ) + end) + + it("cycles from inline to expand with mixed siblings", function() + assert.are.same( + { + [[from foo import (]], + [[ qux,]], + [[ bar,]], + [[ baz,]], + [[)]], + }, + Helper:call({ + [[from foo import qux]], + [[from foo import bar, baz]], + }, {2, 1}) + ) + end) + + it("cycles from expand to single with mixed siblings", function() + assert.are.same( + { + [[from foo import a]], + [[from foo import b]], + [[from foo import c]], + [[from foo import bar]], + [[from foo import baz]], + [[from foo import qux]], + [[from foo import d]], + [[from foo import e]], + }, + Helper:call({ + [[from foo import a, b]], + [[from foo import c]], + [[from foo import (]], + [[ bar,]], + [[ baz,]], + [[ qux,]], + [[)]], + [[from foo import d, e]], + }, {3, 1}) + ) + end) + + it("cycles from inline to expand only close siblings", function() + assert.are.same( + { + [[from abc import a, b, c]], + [[from foo import (]], + [[ bar,]], + [[ baz,]], + [[ qux,]], + [[ bee,]], + [[ boo,]], + [[ hah,]], + [[)]], + [[from xyz import x, y, z]], + }, + Helper:call({ + [[from abc import a, b, c]], + [[from foo import bar, baz, qux]], + [[from foo import bee, boo]], + [[from foo import hah]], + [[from xyz import x, y, z]], + }, {3, 1}) + ) + end) + + it("cycles with relative imports", function() + assert.are.same( + { + [[from .foo import (]], + [[ bar,]], + [[ baz,]], + [[ qux,]], + [[)]], + }, + Helper:call({ + [[from .foo import bar, baz, qux]], + }) + ) + end) + + it("cycles with relative imports", function() + assert.are.same( + { + [[from .foo import bar]], + [[from .foo import baz]], + [[from .foo import qux]], + }, + Helper:call({ + [[from .foo import (]], + [[ bar,]], + [[ baz,]], + [[ qux,]], + [[)]], + }) + ) + end) + + it("cycles with deep relative imports", function() + assert.are.same( + { + [[from .foo.bar.baz import (]], + [[ qux,]], + [[ bee,]], + [[ boo,]], + [[)]], + }, + Helper:call({ + [[from .foo.bar.baz import qux, bee, boo]], + }) + ) + end) + + it("cycles with multi-level relative imports", function() + assert.are.same( + { + [[from ...foo import (]], + [[ bar,]], + [[ baz,]], + [[ qux,]], + [[)]], + }, + Helper:call({ + [[from ...foo import bar, baz, qux]], + }) + ) + end) + + it("cycles with import aliases", function() + assert.are.same( + { + [[from foo import (]], + [[ bar as b,]], + [[ baz as z,]], + [[ qux as q,]], + [[)]], + }, + Helper:call({ + [[from foo import bar as b, baz as z, qux as q]], + }) + ) + end) + + it("doesn't cycle with embedded comments", function() + local text = { + [[from foo import (]], + [[ bar, # comment]], + [[ baz, # comment]], + [[ qux, # comment]], + [[)]], + } + assert.are.same(text, Helper:call(text)) + end) + + it("cycles with sibling comments", function() + assert.are.same( + { + [[from abc import abc]], + [[# comment]], + [[from foo import (]], + [[ bar,]], + [[ baz,]], + [[ qux,]], + [[)]], + [[# comment]], + [[from xyz import x, y, z]], + }, + Helper:call({ + [[from abc import abc]], + [[# comment]], + [[from foo import bar, baz, qux]], + [[# comment]], + [[from xyz import x, y, z]], + }, {3, 1}) + ) + end) + + it("cycles to inline (multiline due to config.line_length = 80)", function() + assert.are.same( + { + [[from json import (loads, dumps, JSONDecodeError as foo, detect_encoding,]], + [[ loads as decode, dumps as encode)]], + }, + Helper:call({ + [[from json import loads]], + [[from json import dumps]], + [[from json import JSONDecodeError as foo]], + [[from json import detect_encoding]], + [[from json import loads as decode]], + [[from json import dumps as encode]], + }) + ) + end) + + it("cycles to expand from multiline inline", function() + assert.are.same( + { + [[from json import (]], + [[ loads,]], + [[ dumps,]], + [[ JSONDecodeError as foo,]], + [[ detect_encoding,]], + [[ loads as decode,]], + [[ dumps as encode,]], + [[)]], + }, + Helper:call({ + [[from json import (loads, dumps, JSONDecodeError as foo, detect_encoding,]], + [[ loads as decode, dumps as encode)]], + }) + ) + end) + + it("cycles to inline (multiline) from indented expand", function() + assert.are.same( + { + [[def foo():]], + [[ from json import (loads, dumps, JSONDecodeError as foo, detect_encoding,]], + [[ loads as decode, dumps as encode)]], + }, + Helper:call({ + [[def foo():]], + [[ from json import loads]], + [[ from json import dumps]], + [[ from json import JSONDecodeError as foo]], + [[ from json import detect_encoding]], + [[ from json import loads as decode]], + [[ from json import dumps as encode]], + }, {2, 5}) + ) + end) + + it("cycles to expand from multiline inline while indented", function() + assert.are.same( + { + [[def foo():]], + [[ from json import (]], + [[ loads,]], + [[ dumps,]], + [[ JSONDecodeError as foo,]], + [[ detect_encoding,]], + [[ loads as decode,]], + [[ dumps as encode,]], + [[ )]], + }, + Helper:call({ + [[def foo():]], + [[ from json import (loads, dumps, JSONDecodeError as foo, detect_encoding,]], + [[ loads as decode, dumps as encode)]], + }, {2, 5}) + ) + end) + +end) diff --git a/spec/filetypes/python/import_statement_spec.lua b/spec/filetypes/python/import_statement_spec.lua new file mode 100644 index 0000000..19268a3 --- /dev/null +++ b/spec/filetypes/python/import_statement_spec.lua @@ -0,0 +1,179 @@ +dofile("./spec/spec_helper.lua") + +local Helper = SpecHelper.new("python", { shiftwidth = 4 }) + +describe("import_statement", function() + + it("doesn't cycle with 1 import (same for both)", function() + assert.are.same( + { + [[import bar]], + }, + Helper:call({ + [[import bar]], + }) + ) + end) + + + it("cycles from single to inline", function() + assert.are.same( + { + [[import bar, baz, qux]], + }, + Helper:call({ + [[import bar]], + [[import baz]], + [[import qux]], + }) + ) + end) + + it("cycles from inline to single", function() + assert.are.same( + { + [[import bar]], + [[import baz]], + [[import qux]], + }, + Helper:call({ + [[import bar, baz, qux]], + }) + ) + end) + + it("cycles from inline to single (inline detected w continuation)", function() + assert.are.same( + { + [[import bar]], + [[import baz]], + }, + Helper:call({ + [[import bar, \]], + [[ baz]], + }) + ) + end) + + it("cycles from inline to single with mixed siblings", function() + assert.are.same( + { + [[import qux]], + [[import bar]], + [[import baz]], + }, + Helper:call({ + [[import qux]], + [[import bar, baz]], + }, {2, 1}) + ) + end) + + it("cycles from single to inline only close siblings", function() + assert.are.same( + { + [[from abc import a, b, c]], + [[import bar, bee, hah]], + [[from xyz import x, y, z]], + }, + Helper:call({ + [[from abc import a, b, c]], + [[import bar]], + [[import bee]], + [[import hah]], + [[from xyz import x, y, z]], + }, {3, 1}) + ) + end) + + it("cycles with deep relative imports", function() + assert.are.same( + { + [[import foo.bar.baz.qux]], + [[import fish.sandwich]], + [[import boo.ghosts]], + }, + Helper:call({ + [[import foo.bar.baz.qux, fish.sandwich, boo.ghosts]], + }) + ) + end) + + it("cycles with import aliases", function() + assert.are.same( + { + [[import foo.bar as b]], + [[import baz as z]], + [[import qux as q]], + }, + Helper:call({ + [[import foo.bar as b, baz as z, qux as q]], + }) + ) + end) + + it("doesn't cycle with comments", function() + local text = { + [[import bar # comment]], + [[import baz # comment]], + [[import qux # comment]], + } + assert.are.same(text, Helper:call(text)) + end) + + it("cycles with sibling comments", function() + assert.are.same( + { + [[from abc import abc]], + [[# comment]], + [[import bar]], + [[import baz]], + [[import qux]], + [[# comment]], + [[from xyz import x, y, z]], + }, + Helper:call({ + [[from abc import abc]], + [[# comment]], + [[import bar, baz, qux]], + [[# comment]], + [[from xyz import x, y, z]], + }, {3, 1}) + ) + end) + + it("cycles to multiline inline (it exceeded config.line_length)", function() + assert.are.same( + { + [[import this.will.be.long, once.its.inlined, it.will.be.too.long, bar, baz, qux]], + [[import abc, xyz, to.fit.on.one.line]], + }, + Helper:call({ + [[import this.will.be.long]], + [[import once.its.inlined]], + [[import it.will.be.too.long]], + [[import bar, baz, qux, abc, xyz]], + [[import to.fit.on.one.line]], + }) + ) + end) + + it("cycles to multiline inline while indented", function() + assert.are.same( + { + [[def foo():]], + [[ import this.will.be.long, once.its.inlined, it.will.be.too.long, bar, baz]], + [[ import qux, abc, xyz, to.fit.on.one.line]], + }, + Helper:call({ + [[def foo():]], + [[ import this.will.be.long]], + [[ import once.its.inlined]], + [[ import it.will.be.too.long]], + [[ import bar, baz, qux, abc, xyz]], + [[ import to.fit.on.one.line]], + }, {2, 5}) + ) + end) + +end)