diff --git a/src/formatter.lua b/src/formatter.lua index 3c4e54f..d087e46 100644 --- a/src/formatter.lua +++ b/src/formatter.lua @@ -1,4 +1,5 @@ -- SCL Formatter - Document formatting for the LSP server +-- Conforms to TIA Portal SCL formatting standards local M = {} local function position(line, character) @@ -12,10 +13,45 @@ 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, +} + +-- 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 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, +} + +-- Block declaration keywords +local block_declarations = { + FUNCTION = true, FUNCTION_BLOCK = true, ORGANIZATION_BLOCK = true, + TYPE = true, STRUCT = 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, +} + function M.format_document(content, options) options = options or {} - local indent_size = options.indentSize or 4 local indent_char = options.insertSpaces and " " or "\t" + local indent_size = options.tabSize or 1 local lines = {} for line in content:gmatch("([^\n]*)\n") do @@ -27,65 +63,150 @@ function M.format_document(content, options) 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 function get_indent() return string.rep(indent_char, indent_level * indent_size) end - local function is_block_keyword(line) - local kw = line:match('^%s*(%w+)') - if not kw then return false end - local block_keywords = { - IF = true, ELSIF = true, ELSE = true, - FOR = true, WHILE = true, REPEAT = true, - CASE = true, - FUNCTION = true, FUNCTION_BLOCK = true, ORGANIZATION_BLOCK = true, - TYPE = true, STRUCT = true, - } - return block_keywords[kw] - end - - local function is_end_block(line) - local kw = line:match('^%s*(%w+)') - if not kw then return false end - local end_keywords = { - END_IF = true, END_FOR = true, END_WHILE = true, END_REPEAT = true, - END_CASE = true, END_FUNCTION = true, END_FUNCTION_BLOCK = true, - END_ORGANIZATION_BLOCK = true, END_TYPE = true, END_STRUCT = true, - } - return end_keywords[kw] - end - - local function process_line(line) + local function get_keyword(line) local trimmed = line:gsub("^%s+", ""):gsub("%s+$", "") + local kw = trimmed:match("^(%w+)") + return kw, trimmed + end - if trimmed == "" or trimmed:match("^//") or trimmed:match("^%(%*") then + 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 ~= "(" + end + return false + end + + local function is_case_label(trimmed) + return trimmed:match("^%w+%s*:") ~= nil + end + + for i, line in ipairs(lines) do + local trimmed = line:gsub("^%s+", ""):gsub("%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 table.insert(formatted_lines, line) - return - end - - if is_end_block(trimmed) then - indent_level = math.max(0, indent_level - 1) - end - - local formatted_line = get_indent() .. trimmed - table.insert(formatted_lines, formatted_line) - - if is_block_keyword(trimmed) and not is_end_block(trimmed) then - if not trimmed:match("CASE%s+.+OF%s*$") and - not trimmed:match("IF%s+.+THEN%s*$") and - not trimmed:match("ELSIF%s+.+THEN%s*$") and - not trimmed:match("ELSE%s*$") and - not trimmed:match("FOR%s+.+DO%s*$") and - not trimmed:match("WHILE%s+.+DO%s*$") and - not trimmed:match("REPEAT%s*$") then + else + -- Check for BEGIN keyword (starts code section) + if kw == "BEGIN" then + in_code_section = true + table.insert(formatted_lines, get_indent() .. trimmed) + 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) + 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) + -- 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) + indent_level = 0 + -- Check for block enders + elseif is_block_ender(kw) then + if kw == "END_IF" then + if_base_level = nil + end + 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 + -- 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, get_indent() .. trimmed) + -- 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 + if kw == "IF" then + if_base_level = indent_level + end + table.insert(formatted_lines, get_indent() .. trimmed) + -- Always increase indent after block starters + indent_level = indent_level + 1 + -- Regular line + else + local formatted_line = get_indent() .. trimmed + if needs_semicolon(trimmed) then + formatted_line = formatted_line .. ";" + end + table.insert(formatted_lines, formatted_line) end end - end - for _, line in ipairs(lines) do - process_line(line) + prev_line_keyword = kw end local result = table.concat(formatted_lines, "\n") @@ -103,8 +224,8 @@ end function M.get_formatting_options() return { - insertSpaces = true, - tabSize = 4, + insertSpaces = false, + tabSize = 1, trimTrailingWhitespace = true, insertFinalNewline = true, trimFinalNewlines = true,