feat: add interactive attribute block toggle with keybindings and commands

Add ability to expand/collapse SCL variable attribute blocks like {EXTERNALACCESSIBLE := 'false'} to {...}

Features:
- Per-variable collapse rules via collapseVariableRules option
- Interactive toggle with <Leader>xa keybinding
- Commands: SCLToggleAttrBlock, SCLExpandAllAttrBlocks, SCLCollapseAllAttrBlocks
- Keybindings: <Leader>xa (toggle), <Leader>xae (expand all), <Leader>xac (collapse all)
- Supports both formatter-collapsed and manually collapsed blocks

Also update AGENTS.md with new documentation and troubleshooting guide
This commit is contained in:
2026-02-19 10:12:58 +01:00
parent 37ab917422
commit 978e46ba45
4 changed files with 377 additions and 125 deletions
+26 -10
View File
@@ -98,8 +98,8 @@ function M.validate_diagnostics(content, workspace_types, root_dir)
end
end
if in_var_section and trimmed:match("^[%w_]+%s*:") then
local var_name = trimmed:match("^([%w_]+)%s*:")
if in_var_section and (trimmed:match("^[%w_]+%s*%b{}%s*:") or trimmed:match("^[%w_]+%s*:")) then
local var_name = trimmed:match("^([%w_]+)%s*%b{}%s*:") or trimmed:match("^([%w_]+)%s*:")
local var_start = line:find(var_name, 1, true)
if var_name and not var_name:match("^%d") then
@@ -114,21 +114,37 @@ function M.validate_diagnostics(content, workspace_types, root_dir)
})
end
local type_part = line:match(":%s*([^;]+)")
local type_part = nil
local brace_depth = 0
local last_colon = nil
for i = 1, #line do
local c = line:sub(i, i)
if c == "{" then
brace_depth = brace_depth + 1
elseif c == "}" then
brace_depth = brace_depth - 1
elseif c == ":" and brace_depth == 0 then
last_colon = i
end
end
if last_colon then
type_part = line:sub(last_colon + 1)
end
if type_part then
type_part = type_part:gsub("%s*:=.*$", "")
type_part = type_part:gsub("%s*$", "")
type_part = type_part:gsub("^%s+", "")
local base_type = type_part:match("^(%w+)")
local upper_type = base_type and base_type:upper() or nil
if base_type and not all_types[base_type] and
base_type ~= "BOOL" and base_type ~= "INT" and base_type ~= "DINT" and
base_type ~= "REAL" and base_type ~= "LREAL" and base_type ~= "BYTE" and
base_type ~= "WORD" and base_type ~= "DWORD" and base_type ~= "LWORD" and
base_type ~= "CHAR" and base_type ~= "STRING" and base_type ~= "WCHAR" and
base_type ~= "WSTRING" and base_type ~= "TIME" and base_type ~= "DATE" and
base_type ~= "TIME_OF_DAY" and base_type ~= "DATE_AND_TIME" and
base_type ~= "S5TIME" and base_type ~= "LTIME" then
upper_type ~= "BOOL" and upper_type ~= "INT" and upper_type ~= "DINT" and
upper_type ~= "REAL" and upper_type ~= "LREAL" and upper_type ~= "BYTE" and
upper_type ~= "WORD" and upper_type ~= "DWORD" and upper_type ~= "LWORD" and
upper_type ~= "CHAR" and upper_type ~= "STRING" and upper_type ~= "WCHAR" and
upper_type ~= "WSTRING" and upper_type ~= "TIME" and upper_type ~= "DATE" and
upper_type ~= "TIME_OF_DAY" and upper_type ~= "DATE_AND_TIME" and
upper_type ~= "S5TIME" and upper_type ~= "LTIME" then
table.insert(diagnostics, {
range = range_to_lsp(line_num, 0, line_num, #line),
severity = DiagnosticSeverity.Warning,
+122 -14
View File
@@ -2,6 +2,17 @@
-- 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 = {
-- Match S7 variable attributes: {EXTERNALACCESSIBLE := 'false'; ...}
"^%s*{%s*EXTERNAL",
-- Match S7 block attributes: { S7_Optimized_Access := 'TRUE' } or {S7_...}
"^%s*{%s*S7_",
-- Match generic attribute blocks with assignments
"^%s*{%s*%w+%s*:=",
}
local function position(line, character)
return { line = line, character = character }
end
@@ -85,6 +96,21 @@ function M.format_document(content, options)
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"
-- Attribute block collapsing options
local collapse_attributes = options.collapseAttributes
if collapse_attributes == nil then
collapse_attributes = true -- Default: enabled
end
-- User can provide custom patterns or extend defaults
local collapse_patterns = options.collapsePatterns or DEFAULT_COLLAPSE_PATTERNS
if options.extendCollapsePatterns then
-- Extend defaults with user patterns
for _, pattern in ipairs(options.extendCollapsePatterns) do
table.insert(collapse_patterns, pattern)
end
end
-- Parse content into lines
local lines = {}
@@ -127,10 +153,73 @@ function M.format_document(content, options)
return base .. string.rep(" ", fb_paren_offset)
end
-- Helper: check if line is a case label
local function is_case_label(trimmed)
return trimmed:match("^[%w_#]+%s*:%s*$") ~= nil
end
-- Helper: check if line is a case label
local function is_case_label(trimmed)
return trimmed:match("^[%w_#]+%s*:%s*$") ~= nil
end
-- Helper: extract variable name from a line (before any attribute block)
local function extract_variable_name(line)
-- Match pattern: variableName{...} or variableName : type
-- Variable names can contain letters, numbers, underscores
local var_name = line:match("^%s*([%w_]+)%s*[%({:]")
return var_name
end
-- Helper: collapse attribute blocks like {EXTERNALACCESSIBLE := 'false'; ...} to {...}
local function collapse_attribute_block(line)
if not collapse_attributes then
return line
end
-- Check if line contains an attribute block
local attr_start, attr_end = line:find("{.-}")
if not attr_start then
return line
end
-- Extract the content before the attribute block
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)
-- Extract variable name for per-variable rules
local var_name = extract_variable_name(line)
-- Check per-variable collapse rules first (highest priority)
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
-- Check if variable name matches
local var_matches = not var_pattern or var_name:match(var_pattern)
-- Check if attribute content matches (if specified)
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
-- Explicitly don't collapse this match
return line
end
end
end
end
-- Check if this attribute block matches any global collapse pattern
for _, pattern in ipairs(collapse_patterns) do
if attr_content:match(pattern) then
-- Collapse to {...}
return before_attr .. "{...}" .. after_attr
end
end
return line
end
-- Helper: check if line starts FB call
local function is_fb_call_start(line)
@@ -202,17 +291,22 @@ function M.format_document(content, options)
local trimmed = line:gsub("^%s+", ""):gsub("%s+$", "")
local kw = get_keyword(trimmed)
-- Handle empty lines (preserve as-is)
if trimmed == "" then
table.insert(formatted_lines, "")
goto continue
end
-- Handle empty lines (preserve as-is)
if trimmed == "" then
table.insert(formatted_lines, "")
goto continue
end
-- Handle comments (re-indent with current level)
if trimmed:match("^//") or trimmed:match("^%(") then
table.insert(formatted_lines, get_indent() .. trimmed)
goto continue
end
-- Handle comments (re-indent with current level)
if trimmed:match("^//") or trimmed:match("^%(") then
-- Apply attribute block collapsing to comments too (for consistency)
local collapsed = collapse_attribute_block(trimmed)
table.insert(formatted_lines, get_indent() .. collapsed)
goto continue
end
-- Apply attribute block collapsing
trimmed = collapse_attribute_block(trimmed)
-- Check for FB call start
if not in_fb_call and is_fb_call_start(trimmed) then
@@ -436,6 +530,20 @@ function M.get_formatting_options()
trimTrailingWhitespace = true,
insertFinalNewline = true,
trimFinalNewlines = true,
collapseAttributes = true, -- Collapse variable attribute blocks to {...}
-- collapsePatterns = { ... }, -- Override default collapse patterns
-- extendCollapsePatterns = { ... }, -- Extend default patterns with custom ones
-- collapseVariableRules = { -- Per-variable collapse rules (highest priority)
-- {
-- variablePattern = "^stat", -- Match variable names starting with "stat"
-- attributePattern = "EXTERNAL", -- Optional: also match attribute content
-- collapse = true, -- true to collapse, false to expand
-- },
-- {
-- variablePattern = "^temp", -- Match variables starting with "temp"
-- collapse = false, -- Never collapse these
-- },
-- },
}
end
+14 -3
View File
@@ -78,15 +78,26 @@ function M.extract_variables(content)
return
end
local var_name = trimmed:match("^([%w_]+)%s*:")
local var_name = trimmed:match("^([%w_]+)%s*%b{}%s*:") or trimmed:match("^([%w_]+)%s*:")
if not var_name then
var_name = trimmed:match("^([%w_]+)%s*,")
var_name = trimmed:match("^([%w_]+)%s*%b{}%s*,") or trimmed:match("^([%w_]+)%s*,")
end
if var_name and not variables[var_name] then
local col = line:find(var_name, 1, true)
if col then
local type_start = line:find(":", 1, true)
local type_start = nil
local brace_depth = 0
for i = 1, #line do
local c = line:sub(i, i)
if c == "{" then
brace_depth = brace_depth + 1
elseif c == "}" then
brace_depth = brace_depth - 1
elseif c == ":" and brace_depth == 0 then
type_start = i
end
end
local var_data_type = "unknown"
if type_start then
local type_part = line:sub(type_start + 1)