From f977d1ea7c43b79cf28f58bf6e79d25f610c019e Mon Sep 17 00:00:00 2001 From: Lazar Bulovan Date: Wed, 18 Feb 2026 14:42:41 +0100 Subject: [PATCH] fix(formatter): Add comment re-indenting and FB call continuation support - Re-indent comments to match current block level - Add FB/function call continuation detection and alignment - Support both #fbname and "FBName" syntax - Fix FB call detection to exclude assignment statements - Continuation lines get +1 indent level --- src/formatter.lua | 131 ++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 114 insertions(+), 17 deletions(-) diff --git a/src/formatter.lua b/src/formatter.lua index d087e46..b4696e1 100644 --- a/src/formatter.lua +++ b/src/formatter.lua @@ -67,11 +67,21 @@ function M.format_document(content, options) local in_var_section = false local in_code_section = false local prev_line_keyword = nil + + -- FB call continuation tracking + local in_fb_call = false + local fb_call_indent = 0 + local fb_name_length = 0 local function get_indent() return string.rep(indent_char, indent_level * indent_size) end + local function get_continuation_indent() + -- Continuation is one additional indent level beyond current + return string.rep(indent_char, (indent_level + 1) * indent_size) + end + local function get_keyword(line) local trimmed = line:gsub("^%s+", ""):gsub("%s+$", "") local kw = trimmed:match("^(%w+)") @@ -118,63 +128,152 @@ function M.format_document(content, options) return trimmed:match("^%w+%s*:") ~= nil end + -- Check if line is an FB/function call start + -- Check if line is an FB/function call start + -- Matches: #fbname( or "FBName"( - but NOT if it has := before the ( + local function is_fb_call_start(line) + local trimmed = line:gsub("^%s+", "") + -- Must start with # or " + local has_fb_syntax = trimmed:match("^#%w+") or trimmed:match('^"') + if not has_fb_syntax then return false end + + -- Check if there's a ( in the line + local paren_pos = trimmed:find("%(") + if not paren_pos then return false end -- No parentheses, not an FB call + + -- Check if := appears BEFORE the ( (which would make it an assignment, not an FB call) + local assign_pos = trimmed:find(":=") + if assign_pos and assign_pos < paren_pos then + return false -- Has := before (, it's an assignment + end + + -- Check if it has unclosed parentheses + local open_count = 0 + local close_count = 0 + for i = 1, #trimmed do + local c = trimmed:sub(i, i) + if c == "(" then open_count = open_count + 1 + elseif c == ")" then close_count = close_count + 1 + end + end + return open_count > 0 and close_count < open_count + end + + -- Check if line is inside an FB call (has unclosed parentheses) + local function is_fb_call_inside(line) + local open_count = 0 + local close_count = 0 + for i = 1, #line do + local c = line:sub(i, i) + if c == "(" then open_count = open_count + 1 + elseif c == ")" then close_count = close_count + 1 + end + end + return open_count > close_count + end + + -- Check if line ends an FB call + local function is_fb_call_end(line) + local trimmed = line:gsub("^%s+", ""):gsub("%s+$", "") + return trimmed:match(".*%)%s*;?%s*$") ~= nil and + not trimmed:match(".*%([^)]*%:?[=]") -- not still inside parameters + end + + -- Check if line is a continuation (ends with ,) + local function is_fb_call_continuation(line) + local trimmed = line:gsub("^%s+", ""):gsub("%s+$", "") + return trimmed:match(".*[,]%s*$") ~= nil + end + for i, line in ipairs(lines) do local trimmed = line:gsub("^%s+", ""):gsub("%s+$", "") + local original_indent = line:match("^%s*") -- Extract full keyword (handle compound keywords like END_IF, END_FUNCTION_BLOCK) local kw = trimmed:match("^(%w+_%w+)") or trimmed:match("^(%w+)") - -- Preserve empty lines and comments as-is - if trimmed == "" or trimmed:match("^//") or trimmed:match("^%(%*") or trimmed:match("^%*/") then + -- Check for FB call start (before comment handling) + -- Only detect if we're not already in an FB call + if not in_fb_call and is_fb_call_start(trimmed) then + -- Extract FB name length (without quotes) + local fb_name = trimmed:match("^#?([%w_]+)") or trimmed:match('^"?([%w_]+)"?') + if fb_name then + fb_name_length = #fb_name + in_fb_call = true + fb_call_indent = indent_level + end + end + + -- Handle comments - re-indent them + if trimmed:match("^//") then + -- Line comment + table.insert(formatted_lines, get_indent() .. trimmed) + elseif trimmed:match("^%(%*") or trimmed:match("^%*%/") then + -- Block comment at start of line + table.insert(formatted_lines, get_indent() .. trimmed) + elseif trimmed == "" then + -- Preserve empty lines as-is table.insert(formatted_lines, line) else + -- Handle FB call continuation + local formatted_line + if in_fb_call then + -- Check if this ends the FB call + if not is_fb_call_inside(trimmed) then + formatted_line = get_continuation_indent() .. trimmed + in_fb_call = false + -- Check if this is a continuation line + elseif is_fb_call_continuation(trimmed) then + formatted_line = get_continuation_indent() .. trimmed + else + formatted_line = get_indent() .. trimmed + end + else + formatted_line = get_indent() .. trimmed + end + -- Check for BEGIN keyword (starts code section) if kw == "BEGIN" then in_code_section = true - table.insert(formatted_lines, get_indent() .. trimmed) + table.insert(formatted_lines, formatted_line) indent_level = 1 -- Check for VAR section start elseif is_var_keyword(kw) then in_var_section = true in_code_section = false indent_level = 0 - table.insert(formatted_lines, get_indent() .. trimmed) + table.insert(formatted_lines, formatted_line) indent_level = 1 -- Check for END_VAR elseif kw == "END_VAR" then in_var_section = false indent_level = 0 - table.insert(formatted_lines, get_indent() .. trimmed) + table.insert(formatted_lines, formatted_line) -- Check for block declaration enders elseif is_block_declaration_ender(kw) then indent_level = math.max(0, indent_level - 1) - local formatted_line = get_indent() .. trimmed if needs_semicolon(trimmed) then formatted_line = formatted_line .. ";" end table.insert(formatted_lines, formatted_line) -- Check for block declaration starters (FUNCTION, FUNCTION_BLOCK, etc.) elseif is_block_declaration(kw) then - table.insert(formatted_lines, get_indent() .. trimmed) + table.insert(formatted_lines, formatted_line) indent_level = 0 -- Check for block enders elseif is_block_ender(kw) then if kw == "END_IF" then if_base_level = nil end + in_fb_call = false -- Reset FB call state indent_level = math.max(0, indent_level - 1) - local formatted_line = get_indent() .. trimmed if needs_semicolon(trimmed) then formatted_line = formatted_line .. ";" end table.insert(formatted_lines, formatted_line) - -- Check for CASE label -- Check for CASE labels (like "1:") elseif is_case_label(trimmed) and prev_line_keyword == "CASE" then - -- CASE label stays at same indent as CASE (already increased) - local formatted_line = get_indent() .. trimmed table.insert(formatted_lines, formatted_line) - -- Increase for content after label indent_level = indent_level + 1 -- Check for same-level keywords (THEN, ELSE, DO, OF on their own line) elseif same_level_keywords[kw] then @@ -182,7 +281,7 @@ function M.format_document(content, options) if (kw == "ELSIF" or kw == "ELSE") and if_base_level then indent_level = if_base_level end - table.insert(formatted_lines, get_indent() .. trimmed) + table.insert(formatted_lines, formatted_line) -- After ELSE/ELSIF, increase indent for content if (kw == "ELSIF" or kw == "ELSE") and if_base_level then indent_level = indent_level + 1 @@ -193,13 +292,11 @@ function M.format_document(content, options) if kw == "IF" then if_base_level = indent_level end - table.insert(formatted_lines, get_indent() .. trimmed) - -- Always increase indent after block starters + table.insert(formatted_lines, formatted_line) indent_level = indent_level + 1 -- Regular line else - local formatted_line = get_indent() .. trimmed - if needs_semicolon(trimmed) then + if needs_semicolon(trimmed) and not in_fb_call then formatted_line = formatted_line .. ";" end table.insert(formatted_lines, formatted_line)