From 978e46ba454d81ffdc78a6b9b03924fa2569e69d Mon Sep 17 00:00:00 2001 From: Lazar Bulovan Date: Thu, 19 Feb 2026 10:12:58 +0100 Subject: [PATCH] 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 xa keybinding - Commands: SCLToggleAttrBlock, SCLExpandAllAttrBlocks, SCLCollapseAllAttrBlocks - Keybindings: xa (toggle), xae (expand all), xac (collapse all) - Supports both formatter-collapsed and manually collapsed blocks Also update AGENTS.md with new documentation and troubleshooting guide --- AGENTS.md | 313 ++++++++++++++++++++++++++++++-------------- src/diagnostics.lua | 36 +++-- src/formatter.lua | 136 +++++++++++++++++-- src/parser.lua | 17 ++- 4 files changed, 377 insertions(+), 125 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4c08939..601dac9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,59 +1,63 @@ -# AGENTS.md - SCL Language Server (Unified) +# AGENTS.md - SCL Language Server A unified Neovim/LazyVim plugin for Siemens SCL language support providing LSP, linting, formatting, syntax highlighting, and auto-completion. -## Reference Project for Testing - -For manual testing and SCL/UDT/DB file examples, use: -``` -~/dev/siemens/projects/scl_lang_support_lazyvim_ref_project -``` - ## Build/Lint/Test Commands -### LSP Server ```bash +# LSP Server make start # Start LSP server make test # Run LSP in test mode -lua src/main.lua --test # Direct test mode execution -``` +lua src/main.lua --test # Direct test execution -### Tree-sitter Parser -```bash +# Tree-sitter Parser tree-sitter generate # Generate parser from grammar.js -tree-sitter test # Run tree-sitter tests -tree-sitter parse # Parse a single SCL file +tree-sitter test # Run parser tests +tree-sitter parse # Parse single SCL file + +# Lua Linting +luacheck src/ # Lint LSP server code +luacheck lua/scl/ # Lint plugin modules ``` ## Project Structure ``` scl_lsp/ -├── src/ # Standalone LSP server (uses dofile) -│ ├── main.lua # Entry point, JSON-RPC protocol -│ ├── parser.lua # Regex-based SCL parser -│ ├── diagnostics.lua # Linter diagnostics -│ └── formatter.lua # Document formatter -├── lua/scl/ # Neovim plugin modules (uses require) -│ ├── blink_cmp_source.lua # blink.cmp completion source -│ ├── udt_parser.lua # UDT (.udt) parser -│ ├── db_parser.lua # Global DB (.db) parser -│ ├── fb_parser.lua # Function Block parser -│ ├── variables.lua # Local variable extraction -│ ├── workspace_types.lua # Workspace scanning -│ └── multiline_params.lua # FB parameter filling -└── queries/ # Tree-sitter queries +├── src/ # LSP server (uses dofile) +│ ├── main.lua # Entry point, JSON-RPC protocol +│ ├── parser.lua # SCL parser +│ ├── diagnostics.lua # Linter +│ ├── formatter.lua # Document formatter +│ ├── treesitter.lua # Tree-sitter integration +│ ├── plc_json.lua # External UDT loading +│ └── 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 +│ ├── variables.lua # Variable extraction +│ ├── workspace_types.lua # Workspace scanning +│ ├── multiline_params.lua +│ ├── auto_prefix.lua +│ └── attr_toggle.lua # Interactive attribute block toggle +├── queries/ # Tree-sitter queries +│ ├── highlights.scm +│ ├── indents.scm +│ └── folds.scm +└── grammar.js # Tree-sitter grammar ``` ## Code Style Guidelines ### Module Pattern -All modules follow the standard Lua module pattern: ```lua --- Module description comment at top +-- File header comment local M = {} --- Private module-level cache +-- Private cache local cache = {} function M.public_function() @@ -66,37 +70,37 @@ return M ``` ### Naming Conventions -- Functions/variables: `snake_case` (e.g., `get_udt_members`, `var_types`) +- Functions/variables: `snake_case` (e.g., `get_udt_members`) - Constants: `UPPER_SNAKE_CASE` (e.g., `PROJECT_MARKERS`) - Private functions: declare as `local function` before public functions -- Boolean variables: prefix with `is_`, `has_` (e.g., `is_udt`, `has_members`) +- 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")` - External dependencies: wrap in `pcall()` for safety -### Comments -- Every file must have a header comment explaining its purpose -- All functions should have a brief comment describing what they do -- Complex logic requires inline comments explaining the "why" -- Non-obvious regex patterns must have explanatory comments +### Code Formatting +- `lua/scl/`: 2 spaces indentation +- `src/`: tab-based indentation +- Max line length: 120 characters +- No trailing whitespace -### Parser Module API Convention -All parser modules (udt_parser, db_parser, fb_parser) must implement: -- `parse_*_file(filepath)` - Parse a file and cache result -- `parse_*_content(content, filename)` - Parse string content -- `get_*(name)` - Get single cached item by name -- `get_all_*_names()` - Return list of all cached names -- `get_*_members(name)` - Get members/fields for a type -- `is_*_type(name)` - Check if name is a known type -- `clear_cache()` - Clear all cached data -- `get_cache_count()` - Return number of cached items +### Parser Module API +All parser modules must implement: +- `parse_*_file(filepath)` - Parse and cache +- `parse_*_content(content, filename)` - Parse string +- `get_*(name)` - Get cached item +- `get_all_*_names()` - List all cached names +- `get_*_members(name)` - Get members/fields +- `is_*_type(name)` - Check if known type +- `clear_cache()` - Clear cached data +- `get_cache_count()` - Return cache size ### Cache Management -- Use module-level local tables for caching: `local cache = {}` -- Clear caches by iterating and setting to nil, not by reassigning: ```lua +local cache = {} + function M.clear_cache() for k in pairs(cache) do cache[k] = nil @@ -106,21 +110,20 @@ end ## Lua Reserved Keywords -Lua reserved keywords (like `end`, `for`, `in`, etc.) cannot be used as table keys directly. Use bracket notation: +Cannot use reserved keywords as table keys directly: ```lua --- WRONG: causes syntax error +-- WRONG: syntax error local range = { start = pos1, end = pos2 } --- CORRECT: use bracket notation +-- CORRECT: bracket notation local range = { start = pos1, ["end"] = pos2 } ``` -This is especially important for LSP range objects which require an `end` field per the LSP specification. +Critical for LSP range objects with `end` field. ## Error Handling ### Return Pattern -Functions that can fail return `nil, "error message"`: ```lua function M.parse_file(filepath) local file = io.open(filepath, "r") @@ -133,7 +136,6 @@ end ``` ### External Dependencies -Always wrap external requires in `pcall()`: ```lua local ok, module = pcall(require, "some_module") if ok and module then @@ -141,72 +143,187 @@ if ok and module then end ``` -### Validation -Check required parameters early and return sensible defaults: -```lua -function M.get_members(type_name) - if not type_name then - return {} - end - -- ... continue -end -``` - -## Completion System - -### Trigger Characters +## Completion Triggers - `#` - Local variables (after BEGIN) -- `.` - Member access (UDT/DB members) +- `.` - Member access (UDT/DB) - `"` - Global DB names - `(` - FB/Function parameters - ` ` (space) - General completion -### Active Portion Detection -When matching patterns in completion, use the "active portion" of the line (after the last operator) to correctly handle expressions like: -```scl -"DB1".member := "DB2".member +## Formatter Options + +The formatter supports collapsing verbose attribute blocks: + +```lua +-- Before formatting: +statCntrPartsIn{EXTERNALACCESSIBLE := 'false'; EXTERNALVISIBLE := 'false'} : Int; + +-- After formatting: +statCntrPartsIn{...} : Int; ``` -Find the last `:=`, `=>`, `=`, `<>`, `>=`, `<=`, `>`, `<`, `AND`, `OR`, `NOT` and only match patterns after it. + +### Formatter Configuration +```lua +local options = { + insertSpaces = false, + tabSize = 1, + collapseAttributes = true, -- Enable attribute collapsing (default: true) + collapsePatterns = { -- Override default patterns + "^%s*{%s*EXTERNAL", + "^%s*{ S7_", + }, + extendCollapsePatterns = { -- Add custom patterns + "^%s*{%s*CUSTOM", + }, +} +``` + +### Default Collapse Patterns +- `^%s*{%s*EXTERNAL` - Variable attributes: `{EXTERNALACCESSIBLE := 'false'; ...}` +- `^%s*{ S7_` - Block attributes: `{ S7_Optimized_Access := 'TRUE' }` +- `^%s*{%s*%w+%s*:=` - Generic attributes with assignments + +### 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 in current file | -| `:SCLShowWorkspaceTypes` | Show workspace UDTs, FBs, and Global DBs count | -| `: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/ | +| `: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 Server Implementation Notes +## 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 | +|-----|------|-------------| +| `xa` | Normal/Insert | Toggle attribute block under cursor | +| `xae` | Normal | Expand all attribute blocks | +| `xac` | Normal | Collapse all attribute blocks | +| `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. + +## 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 -LSP methods like `textDocument/didOpen` are converted to handler names like `textDocument_didOpen`: ```lua local method_name = message.method:gsub("/", "_") local handler = handlers[method_name] ``` ### Request vs Notification -- Requests have `id` field and require a response -- Notifications have no `id` and should not receive a response ```lua if message.id then - -- Only send response for requests + -- Request: send response return { id = message.id, result = result } end +-- Notification: no response needed ``` -### Line Ending Handling -The LSP server strips CRLF (`\r\n`) line endings for Windows compatibility: +### Script Path Pattern ```lua -line = line:gsub("\r$", "") +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" ``` - -### JSON Parser -The standalone JSON parser in `src/json.lua` handles: -- Objects, arrays, strings, numbers, booleans, null -- Escape sequences in strings -- Whitespace skipping diff --git a/src/diagnostics.lua b/src/diagnostics.lua index 9e9d834..b8d9665 100644 --- a/src/diagnostics.lua +++ b/src/diagnostics.lua @@ -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, diff --git a/src/formatter.lua b/src/formatter.lua index f4bb454..3e6aef7 100644 --- a/src/formatter.lua +++ b/src/formatter.lua @@ -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 diff --git a/src/parser.lua b/src/parser.lua index 6f03b63..ea436a4 100644 --- a/src/parser.lua +++ b/src/parser.lua @@ -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)