diff --git a/src/formatter.lua b/src/formatter.lua index 7c92e32..f4bb454 100644 --- a/src/formatter.lua +++ b/src/formatter.lua @@ -1,5 +1,5 @@ -- SCL Formatter - Document formatting for the LSP server --- Conforms to TIA Portal SCL formatting standards +-- Stack-based implementation for proper nested structure handling local M = {} local function position(line, character) @@ -13,355 +13,409 @@ local function range(start_line, start_char, end_line, end_char) } end --- Keywords that start a code block (increase indent AFTER) -local block_starters = { - IF = true, FOR = true, WHILE = true, REPEAT = true, - CASE = true, REGION = true, +-- Block types with their corresponding enders +local BLOCKS = { + -- Control structures + ["IF"] = "END_IF", + ["FOR"] = "END_FOR", + ["WHILE"] = "END_WHILE", + ["REPEAT"] = "END_REPEAT", + ["CASE"] = "END_CASE", + ["REGION"] = "END_REGION", + -- Declarations + ["FUNCTION"] = "END_FUNCTION", + ["FUNCTION_BLOCK"] = "END_FUNCTION_BLOCK", + ["ORGANIZATION_BLOCK"] = "END_ORGANIZATION_BLOCK", + ["TYPE"] = "END_TYPE", + ["STRUCT"] = "END_STRUCT", + -- VAR sections (special handling) + ["VAR"] = "END_VAR", + ["VAR_INPUT"] = "END_VAR", + ["VAR_OUTPUT"] = "END_VAR", + ["VAR_IN_OUT"] = "END_VAR", + ["VAR_TEMP"] = "END_VAR", + ["VAR_CONSTANT"] = "END_VAR", + ["VAR_RETAIN"] = "END_VAR", + ["VAR_NON_RETAIN"] = "END_VAR", } --- Keywords that stay on same level (don't change indent) -local same_level_keywords = { - THEN = true, ELSE = true, ELSIF = true, DO = true, OF = true, +-- Keywords that don't change indent level themselves (handled specially) +local SAME_LEVEL = { + ["THEN"] = true, + ["ELSE"] = true, + ["ELSIF"] = true, + ["DO"] = true, + ["OF"] = true, + ["BEGIN"] = true, } --- Keywords that end a code block (decrease indent BEFORE) -local block_enders = { - END_IF = true, END_FOR = true, END_WHILE = true, END_REPEAT = true, - END_CASE = true, END_REGION = true, +-- Control structure enders that need semicolons +local NEEDS_SEMICOLON = { + ["END_IF"] = true, + ["END_FOR"] = true, + ["END_WHILE"] = true, + ["END_REPEAT"] = true, + ["END_CASE"] = true, + ["END_REGION"] = true, } --- Block declaration keywords -local block_declarations = { - FUNCTION = true, FUNCTION_BLOCK = true, ORGANIZATION_BLOCK = true, - TYPE = true, STRUCT = true, +-- VAR keywords for special handling +local VAR_KEYWORDS = { + ["VAR"] = true, + ["VAR_INPUT"] = true, + ["VAR_OUTPUT"] = true, + ["VAR_IN_OUT"] = true, + ["VAR_TEMP"] = true, + ["VAR_CONSTANT"] = true, + ["VAR_RETAIN"] = true, + ["VAR_NON_RETAIN"] = true, } --- End block declarations -local block_declaration_enders = { - END_FUNCTION = true, END_FUNCTION_BLOCK = true, END_ORGANIZATION_BLOCK = true, - END_TYPE = true, END_STRUCT = true, -} - --- VAR section keywords -local var_keywords = { - VAR = true, VAR_INPUT = true, VAR_OUTPUT = true, VAR_IN_OUT = true, - VAR_TEMP = true, VAR_CONSTANT = true, VAR_RETAIN = true, VAR_NON_RETAIN = true, +-- Declaration keywords (reset indent) +local DECLARATIONS = { + ["FUNCTION"] = true, + ["FUNCTION_BLOCK"] = true, + ["ORGANIZATION_BLOCK"] = true, + ["TYPE"] = true, + ["STRUCT"] = true, } function M.format_document(content, options) options = options or {} - local indent_char = options.insertSpaces and " " or "\t" + local use_spaces = options.insertSpaces or false local indent_size = options.tabSize or 1 + local indent_char = use_spaces and string.rep(" ", indent_size) or "\t" + -- Parse content into lines local lines = {} for line in content:gmatch("([^\n]*)\n") do table.insert(lines, line) end - if #lines == 0 or content:sub(-1) ~= "\n" then - table.insert(lines, "") + local last_line = content:match("([^\n]+)$") + if last_line and last_line ~= "" then + table.insert(lines, last_line) + end + if #lines == 0 then + return { { range = range(0, 0, 0, 0), newText = "" } } end + -- Stack of active blocks + -- Each entry: { type = "IF", indent = 1, is_case = false } + local stack = {} local formatted_lines = {} - local indent_level = 0 - local if_base_level = nil - local in_var_section = false - local in_code_section = false - local prev_line_keyword = nil - local in_multi_line_condition = false -- Track if we're in multi-line IF condition - - -- FB call continuation tracking + + -- FB call state local in_fb_call = false - local fb_call_indent = 0 local fb_name_length = 0 local fb_call_first_line = false + local fb_paren_offset = 0 -- Offset to align parameters with opening paren + -- Helper: get current indent string local function get_indent() - return string.rep(indent_char, indent_level * indent_size) - end - - local function get_continuation_indent() - -- Continuation indent to align parameters - -- Aligns parameter name with position after FB name + opening paren - -- Formula: base indent + FB name length + 1 (for opening paren) + 1 (extra offset) - local base_indent = string.rep(indent_char, indent_level * indent_size) - return base_indent .. string.rep(" ", fb_name_length + 2) - end - - local function get_keyword(line) - local trimmed = line:gsub("^%s+", ""):gsub("%s+$", "") - local kw = trimmed:match("^(%w+)") - return kw, trimmed - end - - local function is_block_starter(kw) - return kw and block_starters[kw] - end - - local function is_block_ender(kw) - return kw and block_enders[kw] - end - - local function is_var_keyword(kw) - return kw and var_keywords[kw] - end - - local function is_block_declaration(kw) - return kw and block_declarations[kw] - end - - local function is_block_declaration_ender(kw) - return kw and block_declaration_enders[kw] - end - - local function needs_semicolon(trimmed) - local kw = trimmed:match("^(%w+_%w+)") or trimmed:match("^(%w+)") - if not kw then return false end - local needs_end = { - END_IF = true, END_FOR = true, END_WHILE = true, END_REPEAT = true, - END_CASE = true, END_REGION = true, - END_FUNCTION = true, END_FUNCTION_BLOCK = true, END_ORGANIZATION_BLOCK = true, - END_TYPE = true, END_STRUCT = true, - } - if needs_end[kw] then - local last_char = trimmed:sub(-1) - return last_char ~= ";" and last_char ~= "(" + local level = 0 + for _, block in ipairs(stack) do + if not block.no_indent then + level = level + 1 + end end - return false + return string.rep(indent_char, level) end + -- Helper: get continuation indent for FB calls + local function get_continuation_indent() + local base = get_indent() + return base .. string.rep(" ", fb_paren_offset) + end + + -- Helper: check if line is a case label local function is_case_label(trimmed) - return trimmed:match("^%w+%s*:") ~= nil + return trimmed:match("^[%w_#]+%s*:%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 ( + -- Helper: check if line starts FB call 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 + if not (trimmed:match("^#%w+") or trimmed:match('^"')) then + return false end - - -- Check if it has unclosed parentheses - local open_count = 0 - local close_count = 0 + local paren_pos = trimmed:find("%(") + if not paren_pos then return false end + local assign_pos = trimmed:find(":=") + if assign_pos and assign_pos < paren_pos then return false end + local open_count, close_count = 0, 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 + 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 + -- Helper: check if FB call ends on this line + local function fb_call_has_closing(line) + local open_count, close_count = 0, 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 + elseif c == ")" then close_count = close_count + 1 end + end + -- FB call ends when we have closes > opens, not when they're equal + -- (equal means no parens on this line, which shouldn't end the call) + return close_count > 0 and close_count >= open_count + end + + -- Helper: check if line ends with comma (FB call continuation) + local function is_continuation(line) + return line:gsub("^%s+", ""):gsub("%s+$", ""):match(".*,$") ~= nil + end + + -- Helper: extract keyword from line + local function get_keyword(trimmed) + return trimmed:match("^(%w+_%w+)") or trimmed:match("^(%w+)") + end + + -- Helper: find case block in stack + local function find_case_block() + for i = #stack, 1, -1 do + if stack[i].type == "CASE" then + return stack[i], i end end - return open_count > close_count + return nil, nil 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 + -- Helper: pop blocks until matching ender found + local function pop_to_ender(ender_type) + while #stack > 0 do + local top = stack[#stack] + table.remove(stack) + local expected_ender = BLOCKS[top.type] + if expected_ender == ender_type then + return true + end + end + return false end - -- Check if line is a continuation (ends with ,) - local function is_fb_call_continuation(line) + -- Process each line + for line_num, line in ipairs(lines) do local trimmed = line:gsub("^%s+", ""):gsub("%s+$", "") - return trimmed:match(".*[,]%s*$") ~= nil - end + local kw = get_keyword(trimmed) - 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+)") + -- Handle empty lines (preserve as-is) + if trimmed == "" then + table.insert(formatted_lines, "") + goto continue + end - -- Check for FB call start (before comment handling) - -- Only detect if we're not already in an FB call + -- Handle comments (re-indent with current level) + if trimmed:match("^//") or trimmed:match("^%(") then + table.insert(formatted_lines, get_indent() .. trimmed) + goto continue + end + + -- Check for FB call start 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 fb_call_first_line = true + -- Calculate paren offset: find position of opening paren + 1 + -- This aligns continuation lines with the content inside the parens + local paren_pos = trimmed:find("%(") + if paren_pos then + fb_paren_offset = paren_pos + 1 -- +1 to align with content after paren + else + fb_paren_offset = fb_name_length + 2 + end 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 is the first line of the FB call - local is_first_line = fb_call_first_line - - -- Check if line ends an FB call (has closing paren) - local has_closing = trimmed:match(".*%)") - if has_closing and not trimmed:match("%(") then - -- This line closes the FB call - use normal indent - formatted_line = get_indent() .. trimmed - in_fb_call = false - fb_call_first_line = false - -- Check if this is a continuation line (ends with ,) - elseif is_fb_call_continuation(trimmed) then - if is_first_line then - -- First line of FB call - use normal indent - formatted_line = get_indent() .. trimmed - fb_call_first_line = false - else - -- Continuation line - use continuation indent - formatted_line = get_continuation_indent() .. trimmed - end - else - -- Still inside FB call but not a continuation - use continuation indent - formatted_line = get_continuation_indent() .. trimmed - end - else - formatted_line = get_indent() .. trimmed - end + -- Handle FB call lines + if in_fb_call then + local is_first = fb_call_first_line + local has_closing = fb_call_has_closing(trimmed) + local is_cont = is_continuation(trimmed) - -- Check for BEGIN keyword (starts code section) - if kw == "BEGIN" then - in_code_section = true - 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, 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, formatted_line) - -- Check for block declaration enders - elseif is_block_declaration_ender(kw) then - indent_level = math.max(0, indent_level - 1) - 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, 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) - if needs_semicolon(trimmed) then - formatted_line = formatted_line .. ";" - end - table.insert(formatted_lines, formatted_line) - -- Check for CASE labels (like "1:") - elseif is_case_label(trimmed) and prev_line_keyword == "CASE" then - table.insert(formatted_lines, formatted_line) - indent_level = indent_level + 1 - -- Check for same-level keywords (THEN, ELSE, DO, OF on their own line) - elseif same_level_keywords[kw] then - -- For THEN on its own line (after IF without THEN on same line), increase indent for content - if kw == "THEN" then - -- Increase indent after THEN for content - indent_level = indent_level + 1 - end - -- For ELSIF and ELSE, reset to IF base level - if (kw == "ELSIF" or kw == "ELSE") and if_base_level then - indent_level = if_base_level - end - 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 - end - -- Check for block starters - elseif is_block_starter(kw) then - -- Save base level for IF statements AFTER checking for THEN - if kw == "IF" then - -- Check if THEN is on the same line - if trimmed:match("THEN%s*$") then - if_base_level = indent_level - in_multi_line_condition = false - indent_level = indent_level + 1 - else - -- THEN on separate line, don't increase yet - if_base_level = indent_level - in_multi_line_condition = true - end - elseif kw == "THEN" then - -- THEN on its own line, increase for content and exit multi-line condition - in_multi_line_condition = false - indent_level = indent_level + 1 - else - -- For other block starters (FOR, WHILE, REGION, CASE), always increase - in_multi_line_condition = false - indent_level = indent_level + 1 - end - table.insert(formatted_lines, formatted_line) - -- Regular line + local formatted + if has_closing then + formatted = (is_first and get_indent() or get_continuation_indent()) .. trimmed + in_fb_call = false + fb_call_first_line = false + elseif is_cont then + formatted = is_first and (get_indent() .. trimmed) or (get_continuation_indent() .. trimmed) + fb_call_first_line = false else - -- Handle multi-line condition continuation - local use_multi_line_indent = false - if in_multi_line_condition and kw ~= "THEN" then - -- This is a continuation of a multi-line IF condition - -- Use +1 indent from IF level - use_multi_line_indent = true - in_multi_line_condition = false -- Exit multi-line mode after first continuation + formatted = get_continuation_indent() .. trimmed + end + table.insert(formatted_lines, formatted) + goto continue + end + + -- Handle block declarations (FUNCTION_BLOCK, etc.) + if DECLARATIONS[kw] then + -- Clear stack for new block + stack = {} + table.insert(formatted_lines, trimmed) + table.insert(stack, { type = kw, indent = 0, is_declaration = true }) + goto continue + end + + -- Handle declaration enders + if kw and not BLOCKS[kw] then + for starter, ender in pairs(BLOCKS) do + if DECLARATIONS[starter] and kw == ender then + stack = {} + table.insert(formatted_lines, trimmed) + goto continue end - - if use_multi_line_indent then - formatted_line = string.rep(indent_char, (indent_level + 1) * indent_size) .. trimmed - else - formatted_line = get_indent() .. trimmed - end - - if needs_semicolon(trimmed) and not in_fb_call then - formatted_line = formatted_line .. ";" - end - table.insert(formatted_lines, formatted_line) end end - prev_line_keyword = kw + -- Handle BEGIN (transition to code section) + if kw == "BEGIN" then + -- Pop any VAR blocks + while #stack > 0 and VAR_KEYWORDS[stack[#stack].type] do + table.remove(stack) + end + table.insert(formatted_lines, get_indent() .. trimmed) + -- BEGIN itself doesn't add indent, but code inside does + goto continue + end + + -- Handle VAR section start + if VAR_KEYWORDS[kw] then + -- Pop to declaration level + while #stack > 0 and not stack[#stack].is_declaration do + table.remove(stack) + end + table.insert(formatted_lines, get_indent() .. trimmed) + table.insert(stack, { type = kw, indent = #stack, is_var = true }) + goto continue + end + + -- Handle END_VAR + if kw == "END_VAR" then + -- Pop VAR blocks + while #stack > 0 and VAR_KEYWORDS[stack[#stack].type] do + table.remove(stack) + end + table.insert(formatted_lines, get_indent() .. trimmed) + goto continue + end + + -- Handle block enders (END_IF, END_FOR, etc.) + if kw and BLOCKS[kw] == nil then + -- Check if it's an ender + for starter, ender in pairs(BLOCKS) do + if kw == ender and not DECLARATIONS[starter] then + -- Pop matching block + pop_to_ender(kw) + -- Add semicolon if needed + local formatted = get_indent() .. trimmed + if NEEDS_SEMICOLON[kw] and trimmed:sub(-1) ~= ";" then + formatted = formatted .. ";" + end + table.insert(formatted_lines, formatted) + goto continue + end + end + end + + -- Handle same-level keywords (THEN, ELSE, ELSIF) + if SAME_LEVEL[kw] then + local formatted + if kw == "THEN" then + -- Pop IF block temporarily to get correct indent + local if_block = nil + for i = #stack, 1, -1 do + if stack[i].type == "IF" then + if_block = stack[i] + break + end + end + if if_block then + formatted = string.rep(indent_char, if_block.indent) .. trimmed + else + formatted = get_indent() .. trimmed + end + table.insert(formatted_lines, formatted) + -- Push content block after THEN + table.insert(stack, { type = "THEN_CONTENT", indent = #stack, no_indent = true }) + elseif kw == "ELSE" or kw == "ELSIF" then + -- Pop THEN_CONTENT if present + if #stack > 0 and stack[#stack].type == "THEN_CONTENT" then + table.remove(stack) + end + -- Find IF block for correct indent + local if_block = nil + for i = #stack, 1, -1 do + if stack[i].type == "IF" then + if_block = stack[i] + break + end + end + if if_block then + formatted = string.rep(indent_char, if_block.indent) .. trimmed + else + formatted = get_indent() .. trimmed + end + table.insert(formatted_lines, formatted) + -- Push new content block + table.insert(stack, { type = "THEN_CONTENT", indent = #stack, no_indent = true }) + else + formatted = get_indent() .. trimmed + table.insert(formatted_lines, formatted) + end + goto continue + end + + -- Handle case labels + if is_case_label(trimmed) then + local case_block, case_idx = find_case_block() + if case_block then + -- Pop to just after CASE + while #stack > case_idx do + table.remove(stack) + end + -- Case label at CASE level + 1 + local label_indent = string.rep(indent_char, case_block.indent + 1) + table.insert(formatted_lines, label_indent .. trimmed) + -- Push case content level + table.insert(stack, { type = "CASE_LABEL", indent = case_block.indent + 1, no_indent = true }) + else + table.insert(formatted_lines, get_indent() .. trimmed) + end + goto continue + end + + -- Handle block starters (IF, FOR, CASE, REGION, etc.) + if kw and BLOCKS[kw] and not VAR_KEYWORDS[kw] and not DECLARATIONS[kw] then + local current_level = 0 + for _, block in ipairs(stack) do + if not block.no_indent then + current_level = current_level + 1 + end + end + local formatted = string.rep(indent_char, current_level) .. trimmed + table.insert(formatted_lines, formatted) + table.insert(stack, { type = kw, indent = current_level }) + goto continue + end + + -- Regular line + local formatted = get_indent() .. trimmed + -- Add semicolon if it looks like it needs one + if NEEDS_SEMICOLON[kw] and trimmed:sub(-1) ~= ";" then + formatted = formatted .. ";" + end + table.insert(formatted_lines, formatted) + + ::continue:: end + -- Join lines local result = table.concat(formatted_lines, "\n") if content:sub(-1) == "\n" then result = result .. "\n"