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
|
-- SCL Formatter - Document formatting for the LSP server
|
||||||
|
-- Conforms to TIA Portal SCL formatting standards
|
||||||
local M = {}
|
local M = {}
|
||||||
|
|
||||||
local function position(line, character)
|
local function position(line, character)
|
||||||
@@ -12,10 +13,45 @@ local function range(start_line, start_char, end_line, end_char)
|
|||||||
}
|
}
|
||||||
end
|
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)
|
function M.format_document(content, options)
|
||||||
options = options or {}
|
options = options or {}
|
||||||
local indent_size = options.indentSize or 4
|
|
||||||
local indent_char = options.insertSpaces and " " or "\t"
|
local indent_char = options.insertSpaces and " " or "\t"
|
||||||
|
local indent_size = options.tabSize or 1
|
||||||
|
|
||||||
local lines = {}
|
local lines = {}
|
||||||
for line in content:gmatch("([^\n]*)\n") do
|
for line in content:gmatch("([^\n]*)\n") do
|
||||||
@@ -27,65 +63,150 @@ function M.format_document(content, options)
|
|||||||
|
|
||||||
local formatted_lines = {}
|
local formatted_lines = {}
|
||||||
local indent_level = 0
|
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()
|
local function get_indent()
|
||||||
return string.rep(indent_char, indent_level * indent_size)
|
return string.rep(indent_char, indent_level * indent_size)
|
||||||
end
|
end
|
||||||
|
|
||||||
local function is_block_keyword(line)
|
local function get_keyword(line)
|
||||||
local kw = line:match('^%s*(%w+)')
|
local trimmed = line:gsub("^%s+", ""):gsub("%s+$", "")
|
||||||
if not kw then return false end
|
local kw = trimmed:match("^(%w+)")
|
||||||
local block_keywords = {
|
return kw, trimmed
|
||||||
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
|
end
|
||||||
|
|
||||||
local function is_end_block(line)
|
local function is_block_starter(kw)
|
||||||
local kw = line:match('^%s*(%w+)')
|
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
|
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_IF = true, END_FOR = true, END_WHILE = true, END_REPEAT = true,
|
||||||
END_CASE = true, END_FUNCTION = true, END_FUNCTION_BLOCK = true,
|
END_CASE = true, END_REGION = true,
|
||||||
END_ORGANIZATION_BLOCK = true, END_TYPE = true, END_STRUCT = 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
|
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+$", "")
|
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)
|
table.insert(formatted_lines, line)
|
||||||
return
|
else
|
||||||
end
|
-- Check for BEGIN keyword (starts code section)
|
||||||
|
if kw == "BEGIN" then
|
||||||
if is_end_block(trimmed) then
|
in_code_section = true
|
||||||
indent_level = math.max(0, indent_level - 1)
|
table.insert(formatted_lines, get_indent() .. trimmed)
|
||||||
end
|
indent_level = 1
|
||||||
|
-- Check for VAR section start
|
||||||
local formatted_line = get_indent() .. trimmed
|
elseif is_var_keyword(kw) then
|
||||||
table.insert(formatted_lines, formatted_line)
|
in_var_section = true
|
||||||
|
in_code_section = false
|
||||||
if is_block_keyword(trimmed) and not is_end_block(trimmed) then
|
indent_level = 0
|
||||||
if not trimmed:match("CASE%s+.+OF%s*$") and
|
table.insert(formatted_lines, get_indent() .. trimmed)
|
||||||
not trimmed:match("IF%s+.+THEN%s*$") and
|
indent_level = 1
|
||||||
not trimmed:match("ELSIF%s+.+THEN%s*$") and
|
-- Check for END_VAR
|
||||||
not trimmed:match("ELSE%s*$") and
|
elseif kw == "END_VAR" then
|
||||||
not trimmed:match("FOR%s+.+DO%s*$") and
|
in_var_section = false
|
||||||
not trimmed:match("WHILE%s+.+DO%s*$") and
|
indent_level = 0
|
||||||
not trimmed:match("REPEAT%s*$") then
|
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
|
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
|
end
|
||||||
end
|
|
||||||
|
|
||||||
for _, line in ipairs(lines) do
|
prev_line_keyword = kw
|
||||||
process_line(line)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
local result = table.concat(formatted_lines, "\n")
|
local result = table.concat(formatted_lines, "\n")
|
||||||
@@ -103,8 +224,8 @@ end
|
|||||||
|
|
||||||
function M.get_formatting_options()
|
function M.get_formatting_options()
|
||||||
return {
|
return {
|
||||||
insertSpaces = true,
|
insertSpaces = false,
|
||||||
tabSize = 4,
|
tabSize = 1,
|
||||||
trimTrailingWhitespace = true,
|
trimTrailingWhitespace = true,
|
||||||
insertFinalNewline = true,
|
insertFinalNewline = true,
|
||||||
trimFinalNewlines = true,
|
trimFinalNewlines = true,
|
||||||
|
|||||||
Reference in New Issue
Block a user