diff --git a/AGENTS.md b/AGENTS.md index ec7713b..aa98fee 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 # 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 `x` prefix): - -| Key | Mode | Description | -|-----|------|-------------| -| `xa` | Normal/Insert | Toggle block under cursor | -| `xae` | Normal | Expand all collapsed blocks | -| `xac` | Normal | Collapse all attribute blocks | - -**How it works:** -1. Place cursor inside any `{...}` block -2. Press `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 | -|-----|------|-------------| -| `mp` | Insert | Fill multiline FB parameters | -| `` | Insert | Jump to next parameter or regular Tab | - -Note: `` is typically `\` (backslash) or `` 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 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 diff --git a/README.md b/README.md index 37236ac..6e177c1 100644 --- a/README.md +++ b/README.md @@ -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 `xae` to expand all blocks in buffer - Use `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 diff --git a/src/formatter.lua b/src/formatter.lua index 78bc3fb..e5c6c31 100644 --- a/src/formatter.lua +++ b/src/formatter.lua @@ -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 - 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 - else - fb_paren_offset = fb_name_length + 2 - end - end - 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 - 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 - end - table.insert(formatted_lines, formatted) + 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) + + -- 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 + in_fb_call = true + 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_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 - -- 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