fix(formatter): Implement TIA Portal-compliant SCL formatting
- Default to tabs (not spaces) per TIA Portal standard - Fix block keyword handling (IF, FOR, WHILE, CASE, REGION, etc.) - Handle VAR section indentation (variables indented inside VAR) - Fix IF/ELSIF/ELSE indentation (ELSIF/ELSE at same level as IF) - Handle CASE labels and nested CASE statements - Add missing semicolons after END_IF, END_FOR, END_CASE Handle block, etc. - enders (END_IF, END_REGION, etc.) correctly - Support compound keywords (END_IF, END_FUNCTION_BLOCK, etc.)
This commit is contained in:
+164
-43
@@ -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]
|
||||
local function get_keyword(line)
|
||||
local trimmed = line:gsub("^%s+", ""):gsub("%s+$", "")
|
||||
local kw = trimmed:match("^(%w+)")
|
||||
return kw, trimmed
|
||||
end
|
||||
|
||||
local function is_end_block(line)
|
||||
local kw = line:match('^%s*(%w+)')
|
||||
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 end_keywords = {
|
||||
local needs_end = {
|
||||
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,
|
||||
END_CASE = true, END_REGION = true,
|
||||
END_FUNCTION = true, END_FUNCTION_BLOCK = true, END_ORGANIZATION_BLOCK = true,
|
||||
END_TYPE = true, END_STRUCT = true,
|
||||
}
|
||||
return end_keywords[kw]
|
||||
if needs_end[kw] then
|
||||
local last_char = trimmed:sub(-1)
|
||||
return last_char ~= ";" and last_char ~= "("
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function process_line(line)
|
||||
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+$", "")
|
||||
|
||||
if trimmed == "" or trimmed:match("^//") or trimmed:match("^%(%*") then
|
||||
-- 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,
|
||||
|
||||
Reference in New Issue
Block a user