-- SCL Formatter - Document formatting for the LSP server -- Stack-based implementation for proper nested structure handling local M = {} -- Default patterns for attribute blocks to collapse -- Users can extend these via options.collapse_patterns local DEFAULT_COLLAPSE_PATTERNS = { "^%s*{%s*EXTERNAL", "^%s*{%s*S7_", "^%s*{%s*%w+%s*:=", } --- Create LSP Position object --- @param line number Line number --- @param character number Character offset --- @return table Position object local function position(line, character) return { line = line, character = character } end --- Create LSP Range object --- @param start_line number Start line --- @param start_char number Start character --- @param end_line number End line --- @param end_char number End character --- @return table Range object local function range(start_line, start_char, end_line, end_char) return { start = position(start_line, start_char), ["end"] = position(end_line, end_char), } end -- Block types with their corresponding enders local BLOCKS = { ["IF"] = "END_IF", ["FOR"] = "END_FOR", ["WHILE"] = "END_WHILE", ["REPEAT"] = "END_REPEAT", ["CASE"] = "END_CASE", ["REGION"] = "END_REGION", ["FUNCTION"] = "END_FUNCTION", ["FUNCTION_BLOCK"] = "END_FUNCTION_BLOCK", ["ORGANIZATION_BLOCK"] = "END_ORGANIZATION_BLOCK", ["TYPE"] = "END_TYPE", ["STRUCT"] = "END_STRUCT", ["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", } local SAME_LEVEL = { ["THEN"] = true, ["ELSE"] = true, ["ELSIF"] = true, ["DO"] = true, ["OF"] = true, ["BEGIN"] = true, } local NEEDS_SEMICOLON = { ["END_IF"] = true, ["END_FOR"] = true, ["END_WHILE"] = true, ["END_REPEAT"] = true, ["END_CASE"] = true, ["END_REGION"] = true, } 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, } local DECLARATIONS = { ["FUNCTION"] = true, ["FUNCTION_BLOCK"] = true, ["ORGANIZATION_BLOCK"] = true, ["TYPE"] = true, ["STRUCT"] = true, } local CONTINUATION_OPERATORS = { ["AND"] = true, ["OR"] = true, ["XOR"] = true, ["NOT"] = true, ["MOD"] = true, } --- Format SCL document content --- Handles indentation, attribute collapsing, and multiline FB calls --- @param content string The SCL source code --- @param options table Formatting options (insertSpaces, tabSize, collapseAttributes) --- @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 end local collapse_patterns = options.collapsePatterns or DEFAULT_COLLAPSE_PATTERNS if options.extendCollapsePatterns then for _, pattern in ipairs(options.extendCollapsePatterns) do table.insert(collapse_patterns, pattern) end end local lines = {} for line in content:gmatch("([^\n]*)\n") do table.insert(lines, line) end 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 local stack = {} local formatted_lines = {} local function get_indent() local level = 0 for _, block in ipairs(stack) do if not block.no_indent then level = level + 1 end end return string.rep(indent_char, level) end local function is_assignment_continuation(trimmed) if not trimmed then return false end local first_word if trimmed:match("^#") then first_word = trimmed:match("^#(%w+)") else first_word = trimmed:match("^(%w+)") end if first_word and CONTINUATION_OPERATORS[first_word:upper()] then return true end return false end local function is_fb_call_start(trimmed) local first_word = trimmed:match("^(#?[%w_]+)") if not first_word then return false end -- Don't treat control structure keywords as FB calls local control_keywords = { IF = true, ELSIF = true, FOR = true, WHILE = true, CASE = true, WITH = true } if control_keywords[first_word:upper()] then return false end if not (trimmed:match("^#?%w+") or trimmed:match('^"')) then return false end -- Find the first ( that is not inside a block comment (* ... *) local paren_pos = nil local comment_depth = 0 for i = 1, #trimmed do local c = trimmed:sub(i, i) local next_c = trimmed:sub(i+1, i+1) if c == "(" and next_c == "*" then comment_depth = comment_depth + 1 elseif c == "*" and next_c == ")" and comment_depth > 0 then comment_depth = comment_depth - 1 elseif c == "(" and comment_depth == 0 then paren_pos = i break end end 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 return true end 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 end end return close_count > 0 and close_count >= open_count end local function format_fb_call_multiline(fb_lines, base_indent, param_indent) local result = {} local all_text = table.concat(fb_lines, " ") local fb_name = all_text:match("^(#?.-)%s*%(") if not fb_name then for _, l in ipairs(fb_lines) do table.insert(result, base_indent .. l) end return result end local paren_start = all_text:find("%(") local paren_end = all_text:find("%);%s*$") or all_text:find("%)%s*$") if not paren_start or not paren_end then for _, l in ipairs(fb_lines) do table.insert(result, base_indent .. l) end return result end local params_text = all_text:sub(paren_start + 1, paren_end - 1) local params = {} local current_param = "" local depth = 0 for i = 1, #params_text do local c = params_text:sub(i, i) if c == "(" then depth = depth + 1 current_param = current_param .. c elseif c == ")" then depth = depth - 1 current_param = current_param .. c elseif c == "," and depth == 0 then local trimmed_param = current_param:match("^%s*(.-)%s*$") if trimmed_param ~= "" then table.insert(params, trimmed_param) end current_param = "" else current_param = current_param .. c end end local last_param = current_param:match("^%s*(.-)%s*$") if last_param and last_param ~= "" then table.insert(params, last_param) end if #params == 0 then return { base_indent .. fb_name .. "();" } end table.insert(result, base_indent .. fb_name .. "(" .. params[1] .. ",") for i = 2, #params do local closing = (i == #params) and ");" or "," table.insert(result, param_indent .. params[i] .. closing) end return result end local function is_case_label(trimmed) return trimmed:match("^[%w_#]+%s*:%s*$") ~= nil end local function extract_variable_name(line) local var_name = line:match("^%s*([%w_]+)%s*[%({:]") return var_name end local function collapse_attribute_block(line) if not collapse_attributes then return line end local attr_start, attr_end = line:find("{.-}") if not attr_start then return line end local before_attr = line:sub(1, attr_start - 1) local attr_content = line:sub(attr_start, attr_end) local after_attr = line:sub(attr_end + 1) local var_name = extract_variable_name(line) if options.collapseVariableRules and var_name then for _, rule in ipairs(options.collapseVariableRules) do local var_pattern = rule.variablePattern local attr_pattern = rule.attributePattern local should_collapse = rule.collapse local var_matches = not var_pattern or var_name:match(var_pattern) local attr_matches = not attr_pattern or attr_content:match(attr_pattern) if var_matches and attr_matches then if should_collapse then return before_attr .. "{...}" .. after_attr else return line end end end end for _, pattern in ipairs(collapse_patterns) do if attr_content:match(pattern) then return before_attr .. "{...}" .. after_attr end end return line end local function get_keyword(trimmed) return trimmed:match("^([%w_]+)") or trimmed:match("^(%w+)") end local function find_case_block() for i = #stack, 1, -1 do if stack[i].type == "CASE" then return stack[i], i end end return nil, nil end 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 -- State tracking local in_fb_call = false local fb_call_lines = {} local fb_base_indent = "" local fb_param_indent = "" local in_assignment = false local assignment_continuation_indent = "" local in_condition = false local condition_indent = "" local in_attr_block = false for line_num, line in ipairs(lines) do local trimmed = line:gsub("^%s+", ""):gsub("%s+$", "") local kw = get_keyword(trimmed) if trimmed == "" then table.insert(formatted_lines, "") goto continue end if trimmed:match("^//") or trimmed:match("^%(") then local collapsed = collapse_attribute_block(trimmed) table.insert(formatted_lines, get_indent() .. collapsed) goto continue end trimmed = collapse_attribute_block(trimmed) -- Track if we're entering or exiting an attribute block local has_open_brace = trimmed:find("{") local has_close_brace = trimmed:find("}") local exiting_attr_block = false if has_open_brace and not has_close_brace then in_attr_block = true elseif has_close_brace and not has_open_brace then in_attr_block = false exiting_attr_block = true end -- Check if this line ends an assignment (has := and ends with ;) local ends_assignment = trimmed:match(":=") and trimmed:match(";%s*$") -- Check if continuing condition (IF/ELSIF condition before THEN) if in_condition and not ends_assignment then local first_word = trimmed:match("^([%w_]+)") local is_then = first_word and first_word:upper() == "THEN" local is_else = first_word and first_word:upper() == "ELSE" local is_elsif = first_word and first_word:upper() == "ELSIF" local is_ender = trimmed:match("^END_") ~= nil local starts_new_statement = is_then or is_else or is_elsif or is_ender if not starts_new_statement then table.insert(formatted_lines, condition_indent .. trimmed) if is_then then in_condition = false end goto continue end in_condition = false end -- Check if continuing assignment -- Continuation if: in assignment AND (starts with operator OR doesn't start new statement) -- BUT NOT if this line ends an assignment OR starts a new assignment (contains :=) OR starts with block ender if in_assignment and not ends_assignment then local starts_with_operator = is_assignment_continuation(trimmed) -- New statement if: starts with keyword OR contains := (new assignment) OR starts with block ender local first_word = trimmed:match("^([%w_]+)") local starts_block_ender = first_word and ( trimmed:match("^END_") or BLOCKS[first_word] ~= nil ) local is_new_statement = trimmed:match(":=") or starts_block_ender or ( trimmed:match("^%w+") and ( SAME_LEVEL[trimmed:match("^([%w_]+)")] or VAR_KEYWORDS[trimmed:match("^([%w_]+)")] ) ) if starts_with_operator or not is_new_statement then table.insert(formatted_lines, assignment_continuation_indent .. trimmed) goto continue end end in_assignment = false -- Check if starting FB call if not in_fb_call and is_fb_call_start(trimmed) then in_fb_call = true fb_call_lines = { trimmed } fb_base_indent = get_indent() local fb_name = trimmed:match("^(#?.-)%s*%(") or trimmed:match("^(#?.-)$") if fb_name then fb_param_indent = fb_base_indent .. string.rep(" ", #fb_name + 1) else fb_param_indent = fb_base_indent .. string.rep(" ", 4) end if fb_call_has_closing(trimmed) then local formatted_fb = format_fb_call_multiline(fb_call_lines, fb_base_indent, fb_param_indent) for _, fline in ipairs(formatted_fb) do table.insert(formatted_lines, fline) end in_fb_call = false fb_call_lines = {} end goto continue end -- Continue collecting FB call lines if in_fb_call then table.insert(fb_call_lines, trimmed) if fb_call_has_closing(trimmed) then local formatted_fb = format_fb_call_multiline(fb_call_lines, fb_base_indent, fb_param_indent) for _, fline in ipairs(formatted_fb) do table.insert(formatted_lines, fline) end in_fb_call = false fb_call_lines = {} end goto continue end if DECLARATIONS[kw] then stack = {} table.insert(formatted_lines, trimmed) table.insert(stack, { type = kw, indent = 0, is_declaration = true }) goto continue end 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 end end if kw == "BEGIN" 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 if VAR_KEYWORDS[kw] then 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 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 if kw and BLOCKS[kw] == nil then for starter, ender in pairs(BLOCKS) do if kw == ender and not DECLARATIONS[starter] then pop_to_ender(kw) 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 if SAME_LEVEL[kw] then local formatted if kw == "THEN" then local then_indent if in_condition and condition_indent ~= "" then then_indent = condition_indent else local if_block = nil for i = #stack, 1, -1 do if stack[i].type == "IF" then if_block = stack[i] break end end then_indent = if_block and string.rep(indent_char, 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 }) in_condition = false elseif kw == "ELSE" or kw == "ELSIF" then if #stack > 0 and stack[#stack].type == "THEN_CONTENT" then table.remove(stack) end 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) table.insert(stack, { type = "THEN_CONTENT", indent = #stack, no_indent = true }) -- 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) end else formatted = get_indent() .. trimmed table.insert(formatted_lines, formatted) end goto continue end if is_case_label(trimmed) then local case_block, case_idx = find_case_block() if case_block then while #stack > case_idx do table.remove(stack) end local label_indent = string.rep(indent_char, 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 }) else table.insert(formatted_lines, get_indent() .. trimmed) end goto continue end 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 }) if kw == "IF" or kw == "ELSIF" then -- 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 -- Continuation lines should be indented one level deeper than IF/ELSIF condition_indent = string.rep(indent_char, 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 end else in_assignment = false end -- Also reset assignment state when in attribute block if in_attr_block then in_assignment = false end local formatted = get_indent() .. trimmed if NEEDS_SEMICOLON[kw] and trimmed:sub(-1) ~= ";" then formatted = formatted .. ";" end table.insert(formatted_lines, formatted) ::continue:: end local result = table.concat(formatted_lines, "\n") if content:sub(-1) == "\n" then result = result .. "\n" end return { { range = range(0, 0, #lines, 0), newText = result, }, } end --- Get default formatting options --- @return table Default options table function M.get_formatting_options() return { insertSpaces = false, tabSize = 1, trimTrailingWhitespace = true, insertFinalNewline = true, trimFinalNewlines = true, collapseAttributes = false, } end return M