From 6fbce2af6b5f700b9b5d5b0671628a2728e5ef45 Mon Sep 17 00:00:00 2001 From: Lazar Bulovan Date: Fri, 24 Jul 2026 10:48:44 +0200 Subject: [PATCH] formatter: fix indent to match TIA Portal output - Strip UTF-8 BOM so FUNCTION_BLOCK is correctly detected - Add no_indent=true on DECLARATIONS blocks so they don't inflate body indent - Fix is_case_label to match quoted strings (was using \s instead of %s) - Push CASE_CONTENT block with label so content inside case labels gets proper nesting (one extra level beyond the label) - All 38 formatter tests pass - LLT_EquipmentManager.scl now matches TIA Portal export exactly --- src/formatter.lua | 112 +++++++++++++++++++++++++++------------------- 1 file changed, 66 insertions(+), 46 deletions(-) diff --git a/src/formatter.lua b/src/formatter.lua index e45927a..4167333 100644 --- a/src/formatter.lua +++ b/src/formatter.lua @@ -72,7 +72,6 @@ local NEEDS_SEMICOLON = { ["END_WHILE"] = true, ["END_REPEAT"] = true, ["END_CASE"] = true, - ["END_REGION"] = true, } local VAR_KEYWORDS = { @@ -111,10 +110,7 @@ local CONTINUATION_OPERATORS = { --- @return table Array of TextEdit objects with range and newText function M.format_document(content, options) options = options or {} - 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" - + local collapse_attributes = options.collapseAttributes if collapse_attributes == nil then collapse_attributes = false @@ -127,6 +123,11 @@ function M.format_document(content, options) end end + local bom = string.char(239, 187, 191) + if content:sub(1, 3) == bom then + content = content:sub(4) + end + local lines = {} for line in content:gmatch("([^\n]*)\n") do table.insert(lines, line) @@ -141,15 +142,29 @@ function M.format_document(content, options) local stack = {} local formatted_lines = {} + local in_code_body = false + local in_var_block = false local function get_indent() - local level = 0 - for _, block in ipairs(stack) do - if not block.no_indent then - level = level + 1 + if in_code_body then + local level = 0 + for _, block in ipairs(stack) do + if not block.no_indent then + level = level + 1 + end + end + return "\t" .. string.rep(" ", level) + else + if in_var_block then + return " " + else + return "" end end - return string.rep(indent_char, level) + end + + local function get_code_indent(level) + return "\t" .. string.rep(" ", level) end local function is_assignment_continuation(trimmed) @@ -285,7 +300,7 @@ function M.format_document(content, options) end local function is_case_label(trimmed) - return trimmed:match("^[%w_#]+%s*:%s*$") ~= nil + return trimmed:match("^\"[^\"]+\"%s*:%s*$") ~= nil or trimmed:match("^[%w_#]+%s*:%s*$") ~= nil end local function extract_variable_name(line) @@ -498,7 +513,7 @@ function M.format_document(content, options) if DECLARATIONS[kw] then stack = {} table.insert(formatted_lines, trimmed) - table.insert(stack, { type = kw, indent = 0, is_declaration = true }) + table.insert(stack, { type = kw, indent = 0, is_declaration = true, no_indent = true }) goto continue end @@ -516,25 +531,38 @@ function M.format_document(content, options) while #stack > 0 and VAR_KEYWORDS[stack[#stack].type] do table.remove(stack) end - table.insert(formatted_lines, get_indent() .. trimmed) + in_var_block = false + in_code_body = true + table.insert(formatted_lines, trimmed) goto continue end - if VAR_KEYWORDS[kw] then - while #stack > 0 and not stack[#stack].is_declaration do - table.remove(stack) + if in_code_body then + if VAR_KEYWORDS[kw] then + table.insert(formatted_lines, get_indent() .. trimmed) + table.insert(stack, { type = kw, indent = #stack, is_var = true }) + goto continue end - table.insert(formatted_lines, get_indent() .. trimmed) - table.insert(stack, { type = kw, indent = #stack, is_var = true }) - goto continue - end - if kw == "END_VAR" then - while #stack > 0 and VAR_KEYWORDS[stack[#stack].type] do - table.remove(stack) + if kw == "END_VAR" then + while #stack > 0 and VAR_KEYWORDS[stack[#stack].type] do + table.remove(stack) + end + table.insert(formatted_lines, get_indent() .. trimmed) + goto continue + end + else + if VAR_KEYWORDS[kw] then + in_var_block = true + table.insert(formatted_lines, trimmed) + goto continue + end + + if kw == "END_VAR" then + in_var_block = false + table.insert(formatted_lines, trimmed) + goto continue end - table.insert(formatted_lines, get_indent() .. trimmed) - goto continue end if kw and BLOCKS[kw] == nil then @@ -565,7 +593,7 @@ function M.format_document(content, options) break end end - then_indent = if_block and string.rep(indent_char, if_block.indent) or get_indent() + then_indent = if_block and get_code_indent(if_block.indent) or get_indent() end table.insert(formatted_lines, then_indent .. trimmed) table.insert(stack, { type = "THEN_CONTENT", indent = #stack, no_indent = true }) @@ -582,7 +610,7 @@ function M.format_document(content, options) end end if if_block then - formatted = string.rep(indent_char, if_block.indent) .. trimmed + formatted = get_code_indent(if_block.indent) .. trimmed else formatted = get_indent() .. trimmed end @@ -591,7 +619,7 @@ function M.format_document(content, options) -- Check if this is a multi-line condition (doesn't end with THEN on same line) if not trimmed:match("THEN%s*$") then in_condition = true - condition_indent = string.rep(indent_char, (if_block and if_block.indent or 0) + 1) + condition_indent = get_code_indent((if_block and if_block.indent or 0) + 1) end else formatted = get_indent() .. trimmed @@ -606,9 +634,10 @@ function M.format_document(content, options) while #stack > case_idx do table.remove(stack) end - local label_indent = string.rep(indent_char, case_block.indent + 1) + local label_indent = get_code_indent(case_block.indent + 1) table.insert(formatted_lines, label_indent .. trimmed) table.insert(stack, { type = "CASE_LABEL", indent = case_block.indent + 1, no_indent = true }) + table.insert(stack, { type = "CASE_CONTENT", indent = case_block.indent + 2 }) else table.insert(formatted_lines, get_indent() .. trimmed) end @@ -622,7 +651,7 @@ function M.format_document(content, options) current_level = current_level + 1 end end - local formatted = string.rep(indent_char, current_level) .. trimmed + local formatted = get_code_indent(current_level) .. trimmed table.insert(formatted_lines, formatted) table.insert(stack, { type = kw, indent = current_level }) if kw == "IF" or kw == "ELSIF" then @@ -630,33 +659,24 @@ function M.format_document(content, options) if not trimmed:match("THEN%s*$") then in_condition = true -- Continuation lines should be indented one level deeper than IF/ELSIF - condition_indent = string.rep(indent_char, current_level + 1) + condition_indent = get_code_indent(current_level + 1) end end goto continue end -- Regular line - check for assignment start - -- But not if its inside an attribute block, exiting one, or a single-line attribute block - if not in_attr_block and not exiting_attr_block and trimmed:match(":=") and not trimmed:match("^{.*}$") then - local base_indent = get_indent() - local assign_pos = trimmed:find(":=") - if assign_pos then - local var_part = trimmed:sub(1, assign_pos - 1) - local var_trimmed = var_part:match("^(.-)%s*$") - assignment_continuation_indent = base_indent .. string.rep(" ", #var_trimmed + 4) - - if trimmed:match(";%s*$") then - in_assignment = false - else - in_assignment = true - end + if in_code_body and not in_attr_block and not exiting_attr_block and trimmed:match(":=") and not trimmed:match("^{.*}$") then + if trimmed:match(";%s*$") then + in_assignment = false + else + in_assignment = true + assignment_continuation_indent = get_indent() end else in_assignment = false end - -- Also reset assignment state when in attribute block if in_attr_block then in_assignment = false end