feat: improve formatter and attribute block handling

- Format multi-line assignments with proper alignment
- Format FB calls in multiline style with parameter alignment
- Auto-expand collapsed attribute blocks before save
- Rename command from LspSCLFormat to SCLFormat
- Condense AGENTS.md and add testing section with reference project
This commit is contained in:
2026-02-23 10:29:05 +01:00
parent fed4dcf93a
commit b1a249d38f
3 changed files with 253 additions and 499 deletions
+34 -277
View File
@@ -1,6 +1,6 @@
# AGENTS.md - SCL Language Server
A unified Neovim/LazyVim plugin for Siemens SCL language support providing LSP, linting, formatting, syntax highlighting, and auto-completion.
A Neovim/LazyVim plugin for Siemens SCL language support providing LSP, linting, formatting, syntax highlighting, and auto-completion.
## Build/Lint/Test Commands
@@ -12,7 +12,7 @@ lua src/main.lua --test # Direct test execution
# Tree-sitter Parser
tree-sitter generate # Generate parser from grammar.js
tree-sitter test # Run parser tests
tree-sitter test # Run all parser tests
tree-sitter parse <file.scl> # Parse single SCL file
# Lua Linting
@@ -31,11 +31,10 @@ scl_lsp/
│ ├── formatter.lua # Document formatter
│ ├── treesitter.lua # Tree-sitter integration
│ ├── plc_json.lua # External UDT/DB loading
│ ├── builtin_instructions.lua # TIA Portal built-in types/FBs/functions
│ ├── builtin_instructions.lua # TIA Portal built-in types
│ └── json.lua # JSON encoder/decoder
├── lua/scl/ # Neovim plugin (uses require)
│ ├── init.lua # Main plugin setup
│ ├── blink_cmp_source.lua
│ ├── udt_parser.lua # UDT parser
│ ├── db_parser.lua # Global DB parser
│ ├── fb_parser.lua # Function Block parser
@@ -43,34 +42,26 @@ scl_lsp/
│ ├── workspace_types.lua # Workspace scanning
│ ├── multiline_params.lua
│ ├── auto_prefix.lua
│ └── attr_toggle.lua # Interactive attribute block toggle
│ └── attr_toggle.lua
├── queries/ # Tree-sitter queries
│ ├── highlights.scm
│ ├── indents.scm
│ └── folds.scm
└── grammar.js # Tree-sitter grammar
```
## Filetype Detection
The LSP requires the `scl` filetype to be set. Add to your Neovim config (e.g., `ftdetect/scl.vim`):
Required in Neovim config (ftdetect/scl.vim):
```vim
au BufRead,BufNewFile *.scl set filetype=scl
au BufRead,BufNewFile *.udt set filetype=scl
au BufRead,BufNewFile *.db set filetype=scl
```
This enables LSP features (completion, goto definition, hover, etc.) for all SCL-related file types.
## Code Style Guidelines
### Module Pattern
```lua
-- File header comment
local M = {}
-- Private cache
local cache = {}
function M.public_function()
@@ -89,8 +80,8 @@ return M
- Booleans: prefix with `is_`, `has_` (e.g., `is_udt`, `has_members`)
### Imports
- `lua/scl/` modules: use `require("scl.module_name")`
- `src/` modules: use `dofile(script_path .. "/module.lua")`
- `lua/scl/` modules: `require("scl.module_name")`
- `src/` modules: `dofile(script_path .. "/module.lua")`
- External dependencies: wrap in `pcall()` for safety
### Code Formatting
@@ -99,6 +90,9 @@ return M
- Max line length: 120 characters
- No trailing whitespace
### Comments
**DO NOT ADD COMMENTS** in code unless explicitly requested by the user.
### Parser Module API
All parser modules must implement:
- `parse_*_file(filepath)` - Parse and cache
@@ -121,291 +115,54 @@ function M.clear_cache()
end
```
## Lua Reserved Keywords
Cannot use reserved keywords as table keys directly:
```lua
-- WRONG: syntax error
local range = { start = pos1, end = pos2 }
-- CORRECT: bracket notation
local range = { start = pos1, ["end"] = pos2 }
```
Critical for LSP range objects with `end` field.
## Error Handling
### Return Pattern
### Error Handling
```lua
function M.parse_file(filepath)
local file = io.open(filepath, "r")
if not file then
return nil, "Cannot open file: " .. filepath
end
-- ... parse logic
return result
end
```
### External Dependencies
```lua
-- External dependencies
local ok, module = pcall(require, "some_module")
if ok and module then
module.do_something()
end
```
## Diagnostics
The LSP validates data types and provides warnings for unknown types. It recognizes:
- **Elementary data types** - BOOL, BYTE, WORD, DWORD, LWORD, CHAR, WCHAR, STRING, WSTRING, INT, DINT, LINT, USINT, SINT, UINT, UDINT, ULINT, REAL, LREAL, TIME, DATE, TOD, TIME_OF_DAY, DATE_AND_TIME, DT, S5TIME, LTIME, DTL
- **Timer/counter types** - IEC_TIMER, TON_TIME, TOF_TIME, TONR_TIME, TP_TIME, CTU, CTD, CTUD
- **TIA Portal built-in instructions** - Functions and function blocks
- **Workspace types** - UDTs from `.udt` files and data blocks from `.db` files
## Completion Triggers
- `#` - Local variables (after BEGIN)
- `.` - Member access (UDT/DB)
- `"` - Global DB names
- `(` - FB/Function parameters
- ` ` (space) - General completion
- `:` - Type declaration (in VAR section)
### Completion Types
The LSP provides auto-completion for:
- **Local variables** - Variables declared in VAR sections
- **UDT types** - User-defined types from `.udt` files
- **Global DBs** - Data blocks from `.db` files
- **Elementary types** - BOOL, INT, DINT, REAL, TIME, etc.
- **TIA Portal built-in FBs** - TON, TOF, TP, CTU, CTD, CTUD, R_TRIG, etc.
- **TIA Portal built-in functions** - ADD, SUB, MUL, DIV, SIN, COS, SQRT, etc.
## Formatter Options
The formatter supports collapsing verbose attribute blocks:
## Lua Reserved Keywords
Cannot use reserved keywords as table keys directly:
```lua
-- Before formatting:
statCntrPartsIn{EXTERNALACCESSIBLE := 'false'; EXTERNALVISIBLE := 'false'} : Int;
-- WRONG
local range = { start = pos1, end = pos2 }
-- After formatting:
statCntrPartsIn{...} : Int;
-- CORRECT
local range = { start = pos1, ["end"] = pos2 }
```
### Formatter Configuration
```lua
local options = {
insertSpaces = false,
tabSize = 1,
collapseAttributes = false, -- Disabled by default (see note below)
collapsePatterns = { -- Override default patterns
"^%s*{%s*EXTERNAL",
"^%s*{ S7_",
},
extendCollapsePatterns = { -- Add custom patterns
"^%s*{%s*CUSTOM",
},
}
```
Critical for LSP range objects with `end` field.
**Note:** `collapseAttributes` is disabled by default to preserve compatibility with the interactive toggle feature (`:SCLToggleAttrBlock`). When the formatter automatically collapses blocks, the original content is lost. To use both formatting and toggle:
1. Format with `:LspSCLFormat` (preserves original content)
2. Manually collapse with `:SCLCollapseAllAttrBlocks` (stores original content)
3. Now toggle/expand works correctly
## Type System
### Default Collapse Patterns
The `:SCLCollapseAllAttrBlocks` command uses case-insensitive patterns:
- `EXTERNAL` - Variable attributes: `{EXTERNALACCESSIBLE := 'false'; EXTERNALVISIBLE := 'false'}`
- `S7_` - Block attributes: `{S7_Optimized_Access := 'TRUE'}`
- `NonRetain` - NonRetain attribute: `{NonRetain}`
- `Retain` - Retain attribute: `{Retain}`
- `%w+ :=` - Generic attributes with assignments: `{key := 'value'}`
- `%w+` - Single word attributes: `{SomeAttribute}`
Works in `.scl`, `.udt`, and `.db` files.
### Per-Variable Collapse Rules
Control collapsing per variable using rules (evaluated before global patterns):
```lua
local options = {
collapseVariableRules = {
-- Collapse attributes for variables starting with "stat"
{
variablePattern = "^stat",
collapse = true,
},
-- Never collapse for "temp" prefix variables
{
variablePattern = "^temp",
collapse = false,
},
-- Collapse only if both variable AND attribute match
{
variablePattern = "^config",
attributePattern = "EXTERNAL",
collapse = true,
},
},
}
```
**Rule properties:**
- `variablePattern` - Lua pattern to match variable name (optional)
- `attributePattern` - Lua pattern to match attribute content (optional)
- `collapse` - `true` to collapse to `{...}`, `false` to keep expanded
Rules are checked in order; first match wins.
## Commands
| Command | Description |
|---------|-------------|
| `:SCLShowVariables` | Show local variables |
| `:SCLShowWorkspaceTypes` | Show workspace types count |
| `:SCLRescanWorkspaceTypes` | Rescan workspace |
| `:SCLPrefixWord` | Prefix word with `#` |
| `:SCLMultilineParams` | Fill FB parameters |
| `:LspSCLFormat` | Format SCL file |
| `:SCLToggleAttrBlock` | Toggle attribute block under cursor |
| `:SCLExpandAllAttrBlocks` | Expand all `{...}` blocks in buffer |
| `:SCLCollapseAllAttrBlocks` | Collapse all matching attribute blocks |
## LSP Features
### Goto Declaration (`gD`)
The LSP supports goto declaration functionality for:
- **Functions** - Jump to function definition in `.scl` files
- **Function Blocks (FB)** - Jump to FB definition in `.scl` files
- **Data Blocks (DB)** - Jump to DB definition in `.scl` and `.db` files
- **User-Defined Types (UDT)** - Jump to type definition in `.scl` and `.udt` files
Usage: Place cursor on a variable or type name and press `gD` (or use LSP client command).
## Auto-Editing Features
### Auto-Uppercase Keywords
When typing SCL keywords followed by a space, they are automatically converted to uppercase:
- `if ``IF `
- `then ``THEN `
- `end_if``END_IF`
- etc.
Works in `.scl`, `.udt`, and `.db` files.
### Auto-Semicolon for Control Structures
When pressing Enter after an `END_*` control structure keyword, a semicolon is automatically added:
- `END_IF``END_IF;`
- `END_FOR``END_FOR;`
- `END_WHILE``END_WHILE;`
- `END_REPEAT``END_REPEAT;`
- `END_CASE``END_CASE;`
Block definitions (END_VAR, END_FUNCTION, etc.) are NOT affected.
### Auto-Prefix Local Variables
Local variables are automatically prefixed with `#` when typing space or semicolon after BEGIN:
- `myVar := ``#myVar := `
- `counter.in``#counter.in` (on dot)
Can be toggled with `:lua require("scl.auto_prefix").toggle()`.
## Keybindings (SCL/UDT files)
### Attribute Block Toggle
Interactive expand/collapse of individual `{...}` blocks (uses `<Leader>x` prefix):
| Key | Mode | Description |
|-----|------|-------------|
| `<Leader>xa` | Normal/Insert | Toggle block under cursor |
| `<Leader>xae` | Normal | Expand all collapsed blocks |
| `<Leader>xac` | Normal | Collapse all attribute blocks |
**How it works:**
1. Place cursor inside any `{...}` block
2. Press `<Leader>xa` to toggle between collapsed `{...}` and expanded content
3. Original content is stored per-buffer and persists until buffer is closed
4. Works on both formatter-collapsed blocks and manually collapsed ones
### Other Keybindings
| Key | Mode | Description |
|-----|------|-------------|
| `<Space>mp` | Insert | Fill multiline FB parameters |
| `<Tab>` | Insert | Jump to next parameter or regular Tab |
Note: `<Leader>` is typically `\` (backslash) or `<Space>` depending on your configuration.
### Troubleshooting
**If commands or keybindings don't work:**
1. Check if setup() was called:
```lua
:lua print(vim.inspect(require("scl_lsp")))
```
2. Verify commands exist:
```vim
:command SCLToggleAttrBlock
```
Should show the command definition.
3. Check for errors during setup:
```lua
:lua require("scl_lsp").setup({})
```
4. Verify leader key:
```vim
:echo mapleader
```
If empty, your leader is `\` (backslash).
5. Manual test keybinding:
```vim
:nmap <Leader>xa
```
Should show the mapping.
### Attribute Toggle After Formatting
**Note:** After using `:LspSCLFormat`, all collapsed attribute blocks will be expanded and the toggle state is reset. This is because formatting changes line numbers and positions. You can collapse blocks again after formatting.
The LSP validates data types and provides warnings for unknown types:
- **Elementary**: BOOL, BYTE, WORD, DWORD, INT, DINT, REAL, TIME, STRING, etc.
- **Timer/counter**: IEC_TIMER, TON_TIME, TOF_TIME, CTU, CTD, CTUD
- **Built-in FBs**: TON, TOF, TP, R_TRIG, F_TRIG, etc.
- **Built-in functions**: ADD, SUB, MUL, DIV, SIN, COS, SQRT, etc.
- **Workspace types**: UDTs from `.udt` files, DBs from `.db` files
## Testing
Manual testing using: `~/dev/siemens/projects/scl_lang_support_lazyvim_ref_project`
Workflow:
1. Open `.scl` file in Neovim
2. Test LSP features (hover, completion, go-to-definition)
3. Verify diagnostics
4. Test formatting
5. Check workspace scanning
## LSP Implementation Notes
### Method Name Conversion
```lua
local method_name = message.method:gsub("/", "_")
local handler = handlers[method_name]
Use the reference project for testing:
```
~/dev/siemens/projects/scl_lang_support_lazyvim_ref_project/
```
### Request vs Notification
```lua
if message.id then
-- Request: send response
return { id = message.id, result = result }
end
-- Notification: no response needed
```
### Script Path Pattern
```lua
local script_path = debug.getinfo(1, "S").source:gsub("^@", ""):match("(.*/)") or ""
if script_path == "" then
script_path = "."
end
package.path = package.path .. ";" .. script_path .. "/?.lua"
```
Contains `.scl`, `.db`, `.udt`, and `.xml` files for comprehensive testing of:
- LSP features (hover, completion, go-to-definition)
- Formatter (`:SCLFormat`)
- Attribute block toggle
- Workspace type scanning
+5 -4
View File
@@ -27,7 +27,9 @@ A comprehensive Neovim/LazyVim plugin providing **Language Server Protocol (LSP)
### Formatter
| Feature | Description |
|---------|-------------|
| **Document Formatting** | Format entire SCL files |
| **Document Formatting** | Format entire SCL files with `:SCLFormat` |
| **Multi-line Assignments** | Preserves and formats multi-line expressions with proper alignment |
| **FB Call Formatting** | Formats function block calls in multiline style |
| **Indentation** | Proper block indentation |
| **Keyword Casing** | Maintains SCL keyword casing |
@@ -119,8 +121,7 @@ require("scl_lsp").setup({
| `:SCLRescanWorkspaceTypes` | Rescan workspace for types and DBs |
| `:SCLPrefixWord` | Manually prefix current word with `#` |
| `:SCLMultilineParams` | Fill multiline parameters for FB/Function call |
| `:LspSCLFormat` | Format current SCL file |
| `:SCLGeneratePlcJson` | Generate plc.data.json from data_types/ |
| `:SCLFormat` | Format current SCL file |
| `:SCLToggleAttrBlock` | Toggle attribute block under cursor |
| `:SCLExpandAllAttrBlocks` | Expand all `{...}` blocks in buffer |
| `:SCLCollapseAllAttrBlocks` | Collapse all matching attribute blocks |
@@ -159,7 +160,7 @@ statCounter{...} : Int;
- Use `<Leader>xae` to expand all blocks in buffer
- Use `<Leader>xac` to collapse all blocks
**Note:** The formatter has automatic collapsing disabled by default to preserve compatibility with the toggle feature. Format with `:LspSCLFormat` first, then use `:SCLCollapseAllAttrBlocks` to collapse while preserving the ability to expand later.
**Note:** Collapsed blocks are automatically expanded before saving to preserve full content on disk. Use `:SCLFormat` to format, then `:SCLCollapseAllAttrBlocks` to collapse while preserving the ability to expand later.
## External UDT Support
+194 -198
View File
@@ -5,11 +5,8 @@ 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*:=",
}
@@ -26,20 +23,17 @@ end
-- Block types with their corresponding enders
local BLOCKS = {
-- Control structures
["IF"] = "END_IF",
["FOR"] = "END_FOR",
["WHILE"] = "END_WHILE",
["REPEAT"] = "END_REPEAT",
["CASE"] = "END_CASE",
["REGION"] = "END_REGION",
-- Declarations
["FUNCTION"] = "END_FUNCTION",
["FUNCTION_BLOCK"] = "END_FUNCTION_BLOCK",
["ORGANIZATION_BLOCK"] = "END_ORGANIZATION_BLOCK",
["TYPE"] = "END_TYPE",
["STRUCT"] = "END_STRUCT",
-- VAR sections (special handling)
["VAR"] = "END_VAR",
["VAR_INPUT"] = "END_VAR",
["VAR_OUTPUT"] = "END_VAR",
@@ -50,7 +44,6 @@ local BLOCKS = {
["VAR_NON_RETAIN"] = "END_VAR",
}
-- Keywords that don't change indent level themselves (handled specially)
local SAME_LEVEL = {
["THEN"] = true,
["ELSE"] = true,
@@ -60,7 +53,6 @@ local SAME_LEVEL = {
["BEGIN"] = true,
}
-- Control structure enders that need semicolons
local NEEDS_SEMICOLON = {
["END_IF"] = true,
["END_FOR"] = true,
@@ -70,7 +62,6 @@ local NEEDS_SEMICOLON = {
["END_REGION"] = true,
}
-- VAR keywords for special handling
local VAR_KEYWORDS = {
["VAR"] = true,
["VAR_INPUT"] = true,
@@ -82,7 +73,6 @@ local VAR_KEYWORDS = {
["VAR_NON_RETAIN"] = true,
}
-- Declaration keywords (reset indent)
local DECLARATIONS = {
["FUNCTION"] = true,
["FUNCTION_BLOCK"] = true,
@@ -91,31 +81,32 @@ local DECLARATIONS = {
["STRUCT"] = true,
}
local CONTINUATION_OPERATORS = {
["AND"] = true,
["OR"] = true,
["XOR"] = true,
["NOT"] = true,
["MOD"] = true,
}
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"
-- Attribute block collapsing options
-- NOTE: Disabled by default to preserve compatibility with attr_toggle.
-- When formatter collapses blocks, the original content is lost.
-- Use :SCLCollapseAllAttrBlocks after formatting instead.
local collapse_attributes = options.collapseAttributes
if collapse_attributes == nil then
collapse_attributes = false -- Default: disabled to preserve toggle functionality
collapse_attributes = false
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 = {}
for line in content:gmatch("([^\n]*)\n") do
table.insert(lines, line)
@@ -128,18 +119,9 @@ function M.format_document(content, options)
return { { range = range(0, 0, 0, 0), newText = "" } }
end
-- Stack of active blocks
-- Each entry: { type = "IF", indent = 1, is_case = false }
local stack = {}
local formatted_lines = {}
-- FB call state
local in_fb_call = false
local fb_name_length = 0
local fb_call_first_line = false
local fb_paren_offset = 0 -- Offset to align parameters with opening paren
-- Helper: get current indent string
local function get_indent()
local level = 0
for _, block in ipairs(stack) do
@@ -150,100 +132,26 @@ function M.format_document(content, options)
return string.rep(indent_char, level)
end
-- Helper: get continuation indent for FB calls
local function get_continuation_indent()
local base = get_indent()
return base .. string.rep(" ", fb_paren_offset)
local function is_assignment_continuation(trimmed)
if not trimmed then return false end
local first_word = trimmed:match("^(%w+)")
if first_word and CONTINUATION_OPERATORS[first_word:upper()] then
return true
end
return false
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)
local trimmed = line:gsub("^%s+", "")
if not (trimmed:match("^#%w+") or trimmed:match('^"')) then
local function is_fb_call_start(trimmed)
if not (trimmed:match("^#?%w+") or trimmed:match('^"')) then
return false
end
local paren_pos = trimmed:find("%(")
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
local open_count, close_count = 0, 0
for i = 1, #trimmed do
local c = trimmed:sub(i, i)
if c == "(" then open_count = open_count + 1
elseif c == ")" then close_count = close_count + 1 end
end
return open_count > 0 and close_count < open_count
return true
end
-- Helper: check if FB call ends on this line
local function fb_call_has_closing(line)
local open_count, close_count = 0, 0
for i = 1, #line do
@@ -251,23 +159,129 @@ function M.format_document(content, options)
if c == "(" then open_count = open_count + 1
elseif c == ")" then close_count = close_count + 1 end
end
-- FB call ends when we have closes > opens, not when they're equal
-- (equal means no parens on this line, which shouldn't end the call)
return close_count > 0 and close_count >= open_count
end
-- Helper: check if line ends with comma (FB call continuation)
local function is_continuation(line)
return line:gsub("^%s+", ""):gsub("%s+$", ""):match(".*,$") ~= nil
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("^(#?[%w_]+)%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
-- Helper: extract keyword from line
local function get_keyword(trimmed)
-- Match VAR_IN_OUT, VAR_CONSTANT, etc. (words with multiple underscores)
return trimmed:match("^([%w_]+)") or trimmed:match("^(%w+)")
end
-- Helper: find case block in stack
local function find_case_block()
for i = #stack, 1, -1 do
if stack[i].type == "CASE" then
@@ -277,7 +291,6 @@ function M.format_document(content, options)
return nil, nil
end
-- Helper: pop blocks until matching ender found
local function pop_to_ender(ender_type)
while #stack > 0 do
local top = stack[#stack]
@@ -290,77 +303,83 @@ function M.format_document(content, options)
return false
end
-- Process each line
-- 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 = ""
for line_num, line in ipairs(lines) do
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 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
-- Check if continuing assignment
if in_assignment and is_assignment_continuation(trimmed) then
table.insert(formatted_lines, assignment_continuation_indent .. trimmed)
goto continue
end
in_assignment = false
-- Check if starting FB call
if not in_fb_call and is_fb_call_start(trimmed) then
local fb_name = trimmed:match("^#?([%w_]+)") or trimmed:match('^"?([%w_]+)"?')
if fb_name then
fb_name_length = #fb_name
in_fb_call = true
fb_call_first_line = true
-- Calculate paren offset: find position of opening paren + 1
-- This aligns continuation lines with the content inside the parens
local paren_pos = trimmed:find("%(")
if paren_pos then
fb_paren_offset = paren_pos + 1 -- +1 to align with content after paren
fb_call_lines = { trimmed }
fb_base_indent = get_indent()
local fb_name = trimmed:match("^(#?[%w_]+)")
if fb_name then
fb_param_indent = fb_base_indent .. string.rep(" ", #fb_name + 1)
else
fb_paren_offset = fb_name_length + 2
end
end
fb_param_indent = fb_base_indent .. string.rep(" ", 4)
end
-- Handle FB call lines
if in_fb_call then
local is_first = fb_call_first_line
local has_closing = fb_call_has_closing(trimmed)
local is_cont = is_continuation(trimmed)
local formatted
if has_closing then
formatted = (is_first and get_indent() or get_continuation_indent()) .. 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_first_line = false
elseif is_cont then
formatted = is_first and (get_indent() .. trimmed) or (get_continuation_indent() .. trimmed)
fb_call_first_line = false
else
formatted = get_continuation_indent() .. trimmed
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
table.insert(formatted_lines, formatted)
goto continue
end
-- Handle block declarations (FUNCTION_BLOCK, etc.)
if DECLARATIONS[kw] then
-- Clear stack for new block
stack = {}
table.insert(formatted_lines, trimmed)
table.insert(stack, { type = kw, indent = 0, is_declaration = true })
goto continue
end
-- Handle declaration enders
if kw and not BLOCKS[kw] then
for starter, ender in pairs(BLOCKS) do
if DECLARATIONS[starter] and kw == ender then
@@ -371,20 +390,15 @@ function M.format_document(content, options)
end
end
-- Handle BEGIN (transition to code section)
if kw == "BEGIN" then
-- Pop any VAR blocks
while #stack > 0 and VAR_KEYWORDS[stack[#stack].type] do
table.remove(stack)
end
table.insert(formatted_lines, get_indent() .. trimmed)
-- BEGIN itself doesn't add indent, but code inside does
goto continue
end
-- Handle VAR section start
if VAR_KEYWORDS[kw] then
-- Pop to declaration level
while #stack > 0 and not stack[#stack].is_declaration do
table.remove(stack)
end
@@ -393,9 +407,7 @@ function M.format_document(content, options)
goto continue
end
-- Handle END_VAR
if kw == "END_VAR" then
-- Pop VAR blocks
while #stack > 0 and VAR_KEYWORDS[stack[#stack].type] do
table.remove(stack)
end
@@ -403,14 +415,10 @@ function M.format_document(content, options)
goto continue
end
-- Handle block enders (END_IF, END_FOR, etc.)
if kw and BLOCKS[kw] == nil then
-- Check if it's an ender
for starter, ender in pairs(BLOCKS) do
if kw == ender and not DECLARATIONS[starter] then
-- Pop matching block
pop_to_ender(kw)
-- Add semicolon if needed
local formatted = get_indent() .. trimmed
if NEEDS_SEMICOLON[kw] and trimmed:sub(-1) ~= ";" then
formatted = formatted .. ";"
@@ -421,11 +429,9 @@ function M.format_document(content, options)
end
end
-- Handle same-level keywords (THEN, ELSE, ELSIF)
if SAME_LEVEL[kw] then
local formatted
if kw == "THEN" then
-- Pop IF block temporarily to get correct indent
local if_block = nil
for i = #stack, 1, -1 do
if stack[i].type == "IF" then
@@ -439,14 +445,11 @@ function M.format_document(content, options)
formatted = get_indent() .. trimmed
end
table.insert(formatted_lines, formatted)
-- Push content block after THEN
table.insert(stack, { type = "THEN_CONTENT", indent = #stack, no_indent = true })
elseif kw == "ELSE" or kw == "ELSIF" then
-- Pop THEN_CONTENT if present
if #stack > 0 and stack[#stack].type == "THEN_CONTENT" then
table.remove(stack)
end
-- Find IF block for correct indent
local if_block = nil
for i = #stack, 1, -1 do
if stack[i].type == "IF" then
@@ -460,7 +463,6 @@ function M.format_document(content, options)
formatted = get_indent() .. trimmed
end
table.insert(formatted_lines, formatted)
-- Push new content block
table.insert(stack, { type = "THEN_CONTENT", indent = #stack, no_indent = true })
else
formatted = get_indent() .. trimmed
@@ -469,18 +471,14 @@ function M.format_document(content, options)
goto continue
end
-- Handle case labels
if is_case_label(trimmed) then
local case_block, case_idx = find_case_block()
if case_block then
-- Pop to just after CASE
while #stack > case_idx do
table.remove(stack)
end
-- Case label at CASE level + 1
local label_indent = string.rep(indent_char, case_block.indent + 1)
table.insert(formatted_lines, label_indent .. trimmed)
-- Push case content level
table.insert(stack, { type = "CASE_LABEL", indent = case_block.indent + 1, no_indent = true })
else
table.insert(formatted_lines, get_indent() .. trimmed)
@@ -488,7 +486,6 @@ function M.format_document(content, options)
goto continue
end
-- Handle block starters (IF, FOR, CASE, REGION, etc.)
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
@@ -502,9 +499,24 @@ function M.format_document(content, options)
goto continue
end
-- Regular line (including variable declarations inside VAR sections)
-- Regular line - check for assignment start
if 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 not trimmed:match(";%s*$") then
in_assignment = true
end
end
else
in_assignment = false
end
local formatted = get_indent() .. trimmed
-- Add semicolon if it looks like it needs one
if NEEDS_SEMICOLON[kw] and trimmed:sub(-1) ~= ";" then
formatted = formatted .. ";"
end
@@ -513,7 +525,6 @@ function M.format_document(content, options)
::continue::
end
-- Join lines
local result = table.concat(formatted_lines, "\n")
if content:sub(-1) == "\n" then
result = result .. "\n"
@@ -534,22 +545,7 @@ function M.get_formatting_options()
trimTrailingWhitespace = true,
insertFinalNewline = true,
trimFinalNewlines = true,
collapseAttributes = false, -- Disabled by default to preserve toggle functionality
-- Set to true to enable automatic collapsing during formatting:
-- collapseAttributes = true,
-- 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
-- },
-- },
collapseAttributes = false,
}
end