Rewrite formatter with stack-based approach for proper nested structure handling

This commit is contained in:
2026-02-18 19:08:54 +01:00
parent 2c4815baec
commit 37ab917422
+333 -279
View File
@@ -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"