From 96c0c4584fc848573a267c9b14646a761e6e29e1 Mon Sep 17 00:00:00 2001 From: Lazar Bulovan Date: Wed, 18 Feb 2026 13:03:14 +0100 Subject: [PATCH 01/11] fix(lsp): Fix LSP server attachment and JSON parsing issues - Replace lspconfig.scl_lsp registration with vim.lsp.start() + FileType autocmd for Neovim 0.11+ compatibility - Fix Lua reserved keyword 'end' in tables (use bracket notation ['end']) - Fix JSON decoder pattern matching bugs (s:find -> s:sub:match) - Add LSP method name conversion (textDocument/didOpen -> textDocument_didOpen) - Handle request vs notification correctly (only respond to requests with id) - Strip CRLF line endings for Windows compatibility - Add semantic token handler aliases - Update documentation with implementation notes --- AGENTS.md | 44 ++++++++++++ README.md | 18 ++++- src/diagnostics.lua | 19 +++++- src/formatter.lua | 2 +- src/json.lua | 161 +++++++++++++++++++++++++------------------- src/main.lua | 48 ++++++++----- 6 files changed, 201 insertions(+), 91 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 90141f4..4c08939 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -104,6 +104,19 @@ function M.clear_cache() end ``` +## Lua Reserved Keywords + +Lua reserved keywords (like `end`, `for`, `in`, etc.) cannot be used as table keys directly. Use bracket notation: +```lua +-- WRONG: causes syntax error +local range = { start = pos1, end = pos2 } + +-- CORRECT: use 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. + ## Error Handling ### Return Pattern @@ -166,3 +179,34 @@ Find the last `:=`, `=>`, `=`, `<>`, `>=`, `<=`, `>`, `<`, `AND`, `OR`, `NOT` an | `:SCLMultilineParams` | Fill multiline parameters for FB/Function call | | `:LspSCLFormat` | Format current SCL file | | `:SCLGeneratePlcJson` | Generate plc.data.json from data_types/ | + +## LSP Server 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 + return { id = message.id, result = result } +end +``` + +### Line Ending Handling +The LSP server strips CRLF (`\r\n`) line endings for Windows compatibility: +```lua +line = line:gsub("\r$", "") +``` + +### 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/README.md b/README.md index bc25a38..98b9e42 100644 --- a/README.md +++ b/README.md @@ -253,10 +253,24 @@ myVar.temperature := 25.5; END_FUNCTION_BLOCK ``` +## Architecture + +The plugin uses a standalone LSP server (`src/main.lua`) that communicates via JSON-RPC over stdio. The server handles: +- Text document synchronization +- Diagnostics (linting) +- Formatting +- Completion, hover, go-to-definition +- Semantic tokens + +The Neovim plugin (`lua/scl_lsp/init.lua`) manages: +- LSP client lifecycle via `vim.lsp.start()` +- FileType autocmd for lazy loading +- blink.cmp integration +- Workspace scanning for UDTs/DBs + ## Requirements -- **Neovim 0.9+** -- **nvim-lspconfig** - LSP client +- **Neovim 0.11+** (uses `vim.lsp.start()`) - **nvim-treesitter** - Syntax highlighting - **blink.cmp** - Optional, for completion - **Lua 5.1+** - LSP server runtime diff --git a/src/diagnostics.lua b/src/diagnostics.lua index c976c77..9e9d834 100644 --- a/src/diagnostics.lua +++ b/src/diagnostics.lua @@ -1,6 +1,21 @@ -- SCL Diagnostics - Linter functionality for the LSP server local M = {} -local parser = require("scl_lsp.parser") + +-- Get script path for loading parser (same pattern as main.lua) +local script_path = debug.getinfo(1, "S").source:gsub("^@", ""):match("(.*/)") or "" +if script_path == "" then + script_path = "." +end +if script_path:sub(1, 1) ~= "/" then + local cwd = io.popen("pwd") + if cwd then + script_path = cwd:read("*l") .. "/" .. script_path + cwd:close() + end +end +package.path = package.path .. ";" .. script_path .. "/?.lua" + +local parser = dofile(script_path .. "/parser.lua") local DiagnosticSeverity = { Error = 1, @@ -19,7 +34,7 @@ end local function range_to_lsp(start_line, start_char, end_line, end_char) return { start = position_to_lsp(start_line, start_char), - end = position_to_lsp(end_line, end_char), + ["end"] = position_to_lsp(end_line, end_char), } end diff --git a/src/formatter.lua b/src/formatter.lua index 81f405f..3c4e54f 100644 --- a/src/formatter.lua +++ b/src/formatter.lua @@ -8,7 +8,7 @@ end local function range(start_line, start_char, end_line, end_char) return { start = position(start_line, start_char), - end = position(end_line, end_char), + ["end"] = position(end_line, end_char), } end diff --git a/src/json.lua b/src/json.lua index f69ebdc..818d637 100644 --- a/src/json.lua +++ b/src/json.lua @@ -13,7 +13,7 @@ end local function escape(s) s = s:gsub("\\", "\\\\") - s = s:gsub("\"", "\\\"") + s = s:gsub('"', '\\"') s = s:gsub("\n", "\\n") s = s:gsub("\r", "\\r") s = s:gsub("\t", "\\t") @@ -29,7 +29,7 @@ function json.encode(data) elseif t == "number" then return tostring(data) elseif t == "string" then - return "\"" .. escape(data) .. "\"" + return '"' .. escape(data) .. '"' elseif t == "table" then local parts = {} if is_array(data) then @@ -39,7 +39,7 @@ function json.encode(data) return "[" .. table.concat(parts, ",") .. "]" else for k, v in pairs(data) do - table.insert(parts, "\"" .. escape(k) .. "\":" .. json.encode(v)) + table.insert(parts, '"' .. escape(k) .. '":' .. json.encode(v)) end return "{" .. table.concat(parts, ",") .. "}" end @@ -49,7 +49,7 @@ function json.encode(data) end local function skip_whitespace(s, i) - while i <= #s and s:find("^%s", i) do + while i <= #s and s:sub(i, i):match("%s") do i = i + 1 end return i @@ -60,7 +60,7 @@ local function parse_string(s, i) i = i + 1 while i <= #s do local c = s:sub(i, i) - if c == "\"" then + if c == '"' then return table.concat(result), i + 1 elseif c == "\\" and i < #s then local next_c = s:sub(i + 1, i + 1) @@ -70,9 +70,9 @@ local function parse_string(s, i) table.insert(result, "\r") elseif next_c == "t" then table.insert(result, "\t") - elseif next_c == "\\\"" then - table.insert(result, "\"") - elseif next_c == "\\\\" then + elseif next_c == '"' then + table.insert(result, '"') + elseif next_c == "\\" then table.insert(result, "\\") else table.insert(result, next_c) @@ -91,28 +91,85 @@ local function parse_number(s, i) if s:sub(i, i) == "-" then i = i + 1 end - while i <= #s and s:find("^%d", s:sub(i, i)) do + while i <= #s and s:sub(i, i):match("%d") do i = i + 1 end if s:sub(i, i) == "." then i = i + 1 - while i <= #s and s:find("^%d", s:sub(i, i)) do + while i <= #s and s:sub(i, i):match("%d") do i = i + 1 end end - if s:sub(i, i):find("^[eE]") then + if s:sub(i, i):match("[eE]") then i = i + 1 - if s:sub(i, i):find("^[+-]") then + if s:sub(i, i):match("[+-]") then i = i + 1 end - while i <= #s and s:find("^%d", s:sub(i, i)) do + while i <= #s and s:sub(i, i):match("%d") do i = i + 1 end end return tonumber(s:sub(start, i - 1)), i end -local function parse_value(s, i) +local parse_value + +local function parse_array(s, i) + local arr = {} + i = i + 1 + while i <= #s do + i = skip_whitespace(s, i) + if s:sub(i, i) == "]" then + return arr, i + 1 + end + local val + val, i = parse_value(s, i) + table.insert(arr, val) + i = skip_whitespace(s, i) + if s:sub(i, i) == "]" then + return arr, i + 1 + end + if s:sub(i, i) == "," then + i = i + 1 + end + end + return nil, i +end + +local function parse_object(s, i) + local obj = {} + i = i + 1 + while i <= #s do + i = skip_whitespace(s, i) + if s:sub(i, i) == "}" then + return obj, i + 1 + end + if s:sub(i, i) == '"' then + local key + key, i = parse_string(s, i) + i = skip_whitespace(s, i) + if s:sub(i, i) ~= ":" then + return nil, i + end + i = i + 1 + local val + val, i = parse_value(s, i) + obj[key] = val + i = skip_whitespace(s, i) + if s:sub(i, i) == "}" then + return obj, i + 1 + end + if s:sub(i, i) == "," then + i = i + 1 + end + else + return nil, i + end + end + return nil, i +end + +parse_value = function(s, i) i = skip_whitespace(s, i) if i > #s then return nil, i @@ -121,66 +178,30 @@ local function parse_value(s, i) local c = s:sub(i, i) if c == "{" then - local obj = {} - i = i + 1 - while i <= #s do - i = skip_whitespace(s, i) - if s:sub(i, i) == "}" then - return obj, i + 1 - end - if s:sub(i, i) == "\"" then - local key - key, i = parse_string(s, i) - i = skip_whitespace(s, i) - if s:sub(i, i) ~= ":" then - return nil, i - end - i = i + 1 - local val - val, i = parse_value(s, i) - obj[key] = val - i = skip_whitespace(s, i) - if s:sub(i, i) == "}" then - return obj, i + 1 - end - if s:sub(i, i) == "," then - i = i + 1 - end - else - return nil, i - end - end - return nil, i + return parse_object(s, i) elseif c == "[" then - local arr = {} - i = i + 1 - while i <= #s do - i = skip_whitespace(s, i) - if s:sub(i, i) == "]" then - return arr, i + 1 - end - local val - val, i = parse_value(s, i) - table.insert(arr, val) - i = skip_whitespace(s, i) - if s:sub(i, i) == "]" then - return arr, i + 1 - end - if s:sub(i, i) == "," then - i = i + 1 - end + return parse_array(s, i) + elseif c == '"' then + return parse_string(s, i) + elseif c == "t" then + if s:sub(i, i + 3) == "true" then + return true, i + 4 end return nil, i - elseif c == "\"" then - return parse_string(s, i) - elseif s:sub(i, i + 3) == "null" then - return nil, i + 4 - elseif s:sub(i, i + 3) == "true" then - return true, i + 4 - elseif s:sub(i, i + 4) == "false" then - return false, i + 5 - else + elseif c == "f" then + if s:sub(i, i + 4) == "false" then + return false, i + 5 + end + return nil, i + elseif c == "n" then + if s:sub(i, i + 3) == "null" then + return nil, i + 4 + end + return nil, i + elseif c == "-" or c:match("%d") then return parse_number(s, i) + else + return nil, i end end diff --git a/src/main.lua b/src/main.lua index 1bbe0a8..2467e78 100644 --- a/src/main.lua +++ b/src/main.lua @@ -296,7 +296,7 @@ function handlers.textDocument_hover(params) contents = { kind = "markdown", value = detail }, range = { start = { line = params.position.line, character = word_start - 1 }, - endPos = { line = params.position.line, character = word_end - 1 }, + ["end"] = { line = params.position.line, character = word_end - 1 }, }, } end @@ -315,7 +315,7 @@ function handlers.textDocument_hover(params) contents = { kind = "markdown", value = detail }, range = { start = { line = params.position.line, character = word_start - 1 }, - endPos = { line = params.position.line, character = word_end - 1 }, + ["end"] = { line = params.position.line, character = word_end - 1 }, }, } end @@ -449,7 +449,7 @@ function handlers.textDocument_definition(params) uri = uri, range = { start = { line = pos.line, character = pos.character }, - endPos = { line = pos.line, character = pos.character + #word }, + ["end"] = { line = pos.line, character = pos.character + #word }, }, } end @@ -478,10 +478,10 @@ function handlers.textDocument_documentSymbol(params) table.insert(symbols, { name = func.kind:upper() .. " " .. func.name, kind = kind_map[func.kind] or 14, - range = { start = { line = func.start, character = 0 }, endPos = { line = func.final, character = 0 } }, + range = { start = { line = func.start, character = 0 }, ["end"] = { line = func.final, character = 0 } }, selectionRange = { start = { line = func.start, character = 0 }, - endPos = { line = func.start, character = #func.name }, + ["end"] = { line = func.start, character = #func.name }, }, containerName = nil, }) @@ -500,11 +500,11 @@ function handlers.textDocument_documentSymbol(params) detail = var_info.type or "VAR", range = { start = { line = var_info.line, character = 0 }, - endPos = { line = var_info.line, character = #var_name }, + ["end"] = { line = var_info.line, character = #var_name }, }, selectionRange = { start = { line = var_info.line, character = var_info.character }, - endPos = { line = var_info.line, character = var_info.character + #var_name }, + ["end"] = { line = var_info.line, character = var_info.character + #var_name }, }, containerName = nil, }) @@ -526,11 +526,11 @@ function handlers.textDocument_documentSymbol(params) detail = detail, range = { start = { line = type_info.start_line or 0, character = 0 }, - endPos = { line = type_info.start_line or 0, character = #type_name }, + ["end"] = { line = type_info.start_line or 0, character = #type_name }, }, selectionRange = { start = { line = type_info.start_line or 0, character = 0 }, - endPos = { line = type_info.start_line or 0, character = #type_name }, + ["end"] = { line = type_info.start_line or 0, character = #type_name }, }, containerName = nil, }) @@ -656,6 +656,10 @@ function handlers.textDocument_semanticTokensRange(params) return handlers.textDocument_semanticTokensFull(params) end +-- Aliases for method names with slashes converted to underscores +handlers["textDocument_semanticTokens_full"] = handlers.textDocument_semanticTokensFull +handlers["textDocument_semanticTokens_range"] = handlers.textDocument_semanticTokensRange + function handlers.textDocument_inlayHint(params) local uri = params.textDocument.uri local doc = documents[uri] @@ -708,7 +712,7 @@ function handlers.workspace_symbol(params) uri = uri, range = { start = { line = line, character = col }, - endPos = { line = line, character = col + length }, + ["end"] = { line = line, character = col + length }, }, }, }) @@ -848,17 +852,26 @@ local function handle_message(message) if message.method == "$/cancelRequest" then return nil end - local handler = handlers[message.method] + -- Convert method name from "textDocument/didOpen" to "textDocument_didOpen" + local method_name = message.method:gsub("/", "_") + local handler = handlers[method_name] if handler then local ok, result = pcall(handler, message.params) - if ok then - return { id = message.id, result = result } - else - return { id = message.id, error = { code = -32603, message = tostring(result) } } + -- Only return response for requests (have id), not notifications + if message.id then + if ok then + return { id = message.id, result = result } + else + return { id = message.id, error = { code = -32603, message = tostring(result) } } + end end else - return { id = message.id, error = { code = -32601, message = "Method not found: " .. message.method } } + -- Only return error for requests (have id) + if message.id then + return { id = message.id, error = { code = -32601, message = "Method not found: " .. message.method } } + end end + return nil end local function main() @@ -871,6 +884,9 @@ local function main() if not line then break end + -- Strip trailing \r (Windows line endings) + line = line:gsub("\r$", "") + if line:match("^Content%-Length:%s*(%d+)") then content_length = tonumber(line:match("^Content%-Length:%s*(%d+)")) elseif line == "" then From d8e50b3e231b6a42561767a2b2ffecdbadb74828 Mon Sep 17 00:00:00 2001 From: Lazar Bulovan Date: Wed, 18 Feb 2026 14:10:50 +0100 Subject: [PATCH 02/11] fix(formatter): Implement TIA Portal-compliant SCL formatting - Default to tabs (not spaces) per TIA Portal standard - Fix block keyword handling (IF, FOR, WHILE, CASE, REGION, etc.) - Handle VAR section indentation (variables indented inside VAR) - Fix IF/ELSIF/ELSE indentation (ELSIF/ELSE at same level as IF) - Handle CASE labels and nested CASE statements - Add missing semicolons after END_IF, END_FOR, END_CASE Handle block, etc. - enders (END_IF, END_REGION, etc.) correctly - Support compound keywords (END_IF, END_FUNCTION_BLOCK, etc.) --- src/formatter.lua | 221 +++++++++++++++++++++++++++++++++++----------- 1 file changed, 171 insertions(+), 50 deletions(-) diff --git a/src/formatter.lua b/src/formatter.lua index 3c4e54f..d087e46 100644 --- a/src/formatter.lua +++ b/src/formatter.lua @@ -1,4 +1,5 @@ -- SCL Formatter - Document formatting for the LSP server +-- Conforms to TIA Portal SCL formatting standards local M = {} local function position(line, character) @@ -12,10 +13,45 @@ local function range(start_line, start_char, end_line, end_char) } end +-- Keywords that start a code block (increase indent AFTER) +local block_starters = { + IF = true, FOR = true, WHILE = true, REPEAT = true, + CASE = true, REGION = true, +} + +-- Keywords that stay on same level (don't change indent) +local same_level_keywords = { + THEN = true, ELSE = true, ELSIF = true, DO = true, OF = true, +} + +-- Keywords that end a code block (decrease indent BEFORE) +local block_enders = { + END_IF = true, END_FOR = true, END_WHILE = true, END_REPEAT = true, + END_CASE = true, END_REGION = true, +} + +-- Block declaration keywords +local block_declarations = { + FUNCTION = true, FUNCTION_BLOCK = true, ORGANIZATION_BLOCK = true, + TYPE = true, STRUCT = true, +} + +-- End block declarations +local block_declaration_enders = { + END_FUNCTION = true, END_FUNCTION_BLOCK = true, END_ORGANIZATION_BLOCK = true, + END_TYPE = true, END_STRUCT = true, +} + +-- VAR section keywords +local var_keywords = { + VAR = true, VAR_INPUT = true, VAR_OUTPUT = true, VAR_IN_OUT = true, + VAR_TEMP = true, VAR_CONSTANT = true, VAR_RETAIN = true, VAR_NON_RETAIN = true, +} + function M.format_document(content, options) options = options or {} - local indent_size = options.indentSize or 4 local indent_char = options.insertSpaces and " " or "\t" + local indent_size = options.tabSize or 1 local lines = {} for line in content:gmatch("([^\n]*)\n") do @@ -27,65 +63,150 @@ function M.format_document(content, options) local formatted_lines = {} local indent_level = 0 + local if_base_level = nil + local in_var_section = false + local in_code_section = false + local prev_line_keyword = nil local function get_indent() return string.rep(indent_char, indent_level * indent_size) end - local function is_block_keyword(line) - local kw = line:match('^%s*(%w+)') - if not kw then return false end - local block_keywords = { - IF = true, ELSIF = true, ELSE = true, - FOR = true, WHILE = true, REPEAT = true, - CASE = true, - FUNCTION = true, FUNCTION_BLOCK = true, ORGANIZATION_BLOCK = true, - TYPE = true, STRUCT = true, - } - return block_keywords[kw] - end - - local function is_end_block(line) - local kw = line:match('^%s*(%w+)') - if not kw then return false end - local end_keywords = { - END_IF = true, END_FOR = true, END_WHILE = true, END_REPEAT = true, - END_CASE = true, END_FUNCTION = true, END_FUNCTION_BLOCK = true, - END_ORGANIZATION_BLOCK = true, END_TYPE = true, END_STRUCT = true, - } - return end_keywords[kw] - end - - local function process_line(line) + local function get_keyword(line) local trimmed = line:gsub("^%s+", ""):gsub("%s+$", "") + local kw = trimmed:match("^(%w+)") + return kw, trimmed + end - if trimmed == "" or trimmed:match("^//") or trimmed:match("^%(%*") then + local function is_block_starter(kw) + return kw and block_starters[kw] + end + + local function is_block_ender(kw) + return kw and block_enders[kw] + end + + local function is_var_keyword(kw) + return kw and var_keywords[kw] + end + + local function is_block_declaration(kw) + return kw and block_declarations[kw] + end + + local function is_block_declaration_ender(kw) + return kw and block_declaration_enders[kw] + end + + local function needs_semicolon(trimmed) + local kw = trimmed:match("^(%w+_%w+)") or trimmed:match("^(%w+)") + if not kw then return false end + local needs_end = { + END_IF = true, END_FOR = true, END_WHILE = true, END_REPEAT = true, + END_CASE = true, END_REGION = true, + END_FUNCTION = true, END_FUNCTION_BLOCK = true, END_ORGANIZATION_BLOCK = true, + END_TYPE = true, END_STRUCT = true, + } + if needs_end[kw] then + local last_char = trimmed:sub(-1) + return last_char ~= ";" and last_char ~= "(" + end + return false + end + + local function is_case_label(trimmed) + return trimmed:match("^%w+%s*:") ~= nil + end + + for i, line in ipairs(lines) do + local trimmed = line:gsub("^%s+", ""):gsub("%s+$", "") + + -- Extract full keyword (handle compound keywords like END_IF, END_FUNCTION_BLOCK) + local kw = trimmed:match("^(%w+_%w+)") or trimmed:match("^(%w+)") + + -- Preserve empty lines and comments as-is + if trimmed == "" or trimmed:match("^//") or trimmed:match("^%(%*") or trimmed:match("^%*/") then table.insert(formatted_lines, line) - return - end - - if is_end_block(trimmed) then - indent_level = math.max(0, indent_level - 1) - end - - local formatted_line = get_indent() .. trimmed - table.insert(formatted_lines, formatted_line) - - if is_block_keyword(trimmed) and not is_end_block(trimmed) then - if not trimmed:match("CASE%s+.+OF%s*$") and - not trimmed:match("IF%s+.+THEN%s*$") and - not trimmed:match("ELSIF%s+.+THEN%s*$") and - not trimmed:match("ELSE%s*$") and - not trimmed:match("FOR%s+.+DO%s*$") and - not trimmed:match("WHILE%s+.+DO%s*$") and - not trimmed:match("REPEAT%s*$") then + else + -- Check for BEGIN keyword (starts code section) + if kw == "BEGIN" then + in_code_section = true + table.insert(formatted_lines, get_indent() .. trimmed) + indent_level = 1 + -- Check for VAR section start + elseif is_var_keyword(kw) then + in_var_section = true + in_code_section = false + indent_level = 0 + table.insert(formatted_lines, get_indent() .. trimmed) + indent_level = 1 + -- Check for END_VAR + elseif kw == "END_VAR" then + in_var_section = false + indent_level = 0 + table.insert(formatted_lines, get_indent() .. trimmed) + -- Check for block declaration enders + elseif is_block_declaration_ender(kw) then + indent_level = math.max(0, indent_level - 1) + local formatted_line = get_indent() .. trimmed + if needs_semicolon(trimmed) then + formatted_line = formatted_line .. ";" + end + table.insert(formatted_lines, formatted_line) + -- Check for block declaration starters (FUNCTION, FUNCTION_BLOCK, etc.) + elseif is_block_declaration(kw) then + table.insert(formatted_lines, get_indent() .. trimmed) + indent_level = 0 + -- Check for block enders + elseif is_block_ender(kw) then + if kw == "END_IF" then + if_base_level = nil + end + indent_level = math.max(0, indent_level - 1) + local formatted_line = get_indent() .. trimmed + if needs_semicolon(trimmed) then + formatted_line = formatted_line .. ";" + end + table.insert(formatted_lines, formatted_line) + -- Check for CASE label + -- Check for CASE labels (like "1:") + elseif is_case_label(trimmed) and prev_line_keyword == "CASE" then + -- CASE label stays at same indent as CASE (already increased) + local formatted_line = get_indent() .. trimmed + table.insert(formatted_lines, formatted_line) + -- Increase for content after label indent_level = indent_level + 1 + -- Check for same-level keywords (THEN, ELSE, DO, OF on their own line) + elseif same_level_keywords[kw] then + -- For ELSIF and ELSE, reset to IF base level + if (kw == "ELSIF" or kw == "ELSE") and if_base_level then + indent_level = if_base_level + end + table.insert(formatted_lines, get_indent() .. trimmed) + -- After ELSE/ELSIF, increase indent for content + if (kw == "ELSIF" or kw == "ELSE") and if_base_level then + indent_level = indent_level + 1 + end + -- Check for block starters + elseif is_block_starter(kw) then + -- Save base level for IF statements + if kw == "IF" then + if_base_level = indent_level + end + table.insert(formatted_lines, get_indent() .. trimmed) + -- Always increase indent after block starters + indent_level = indent_level + 1 + -- Regular line + else + local formatted_line = get_indent() .. trimmed + if needs_semicolon(trimmed) then + formatted_line = formatted_line .. ";" + end + table.insert(formatted_lines, formatted_line) end end - end - for _, line in ipairs(lines) do - process_line(line) + prev_line_keyword = kw end local result = table.concat(formatted_lines, "\n") @@ -103,8 +224,8 @@ end function M.get_formatting_options() return { - insertSpaces = true, - tabSize = 4, + insertSpaces = false, + tabSize = 1, trimTrailingWhitespace = true, insertFinalNewline = true, trimFinalNewlines = true, From f977d1ea7c43b79cf28f58bf6e79d25f610c019e Mon Sep 17 00:00:00 2001 From: Lazar Bulovan Date: Wed, 18 Feb 2026 14:42:41 +0100 Subject: [PATCH 03/11] fix(formatter): Add comment re-indenting and FB call continuation support - Re-indent comments to match current block level - Add FB/function call continuation detection and alignment - Support both #fbname and "FBName" syntax - Fix FB call detection to exclude assignment statements - Continuation lines get +1 indent level --- src/formatter.lua | 131 ++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 114 insertions(+), 17 deletions(-) diff --git a/src/formatter.lua b/src/formatter.lua index d087e46..b4696e1 100644 --- a/src/formatter.lua +++ b/src/formatter.lua @@ -67,11 +67,21 @@ function M.format_document(content, options) local in_var_section = false local in_code_section = false local prev_line_keyword = nil + + -- FB call continuation tracking + local in_fb_call = false + local fb_call_indent = 0 + local fb_name_length = 0 local function get_indent() return string.rep(indent_char, indent_level * indent_size) end + local function get_continuation_indent() + -- Continuation is one additional indent level beyond current + return string.rep(indent_char, (indent_level + 1) * indent_size) + end + local function get_keyword(line) local trimmed = line:gsub("^%s+", ""):gsub("%s+$", "") local kw = trimmed:match("^(%w+)") @@ -118,63 +128,152 @@ function M.format_document(content, options) return trimmed:match("^%w+%s*:") ~= nil end + -- Check if line is an FB/function call start + -- Check if line is an FB/function call start + -- Matches: #fbname( or "FBName"( - but NOT if it has := before the ( + local function is_fb_call_start(line) + local trimmed = line:gsub("^%s+", "") + -- Must start with # or " + local has_fb_syntax = trimmed:match("^#%w+") or trimmed:match('^"') + if not has_fb_syntax then return false end + + -- Check if there's a ( in the line + local paren_pos = trimmed:find("%(") + if not paren_pos then return false end -- No parentheses, not an FB call + + -- Check if := appears BEFORE the ( (which would make it an assignment, not an FB call) + local assign_pos = trimmed:find(":=") + if assign_pos and assign_pos < paren_pos then + return false -- Has := before (, it's an assignment + end + + -- Check if it has unclosed parentheses + local open_count = 0 + local close_count = 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 + end + + -- Check if line is inside an FB call (has unclosed parentheses) + local function is_fb_call_inside(line) + local open_count = 0 + local close_count = 0 + for i = 1, #line do + local c = line:sub(i, i) + if c == "(" then open_count = open_count + 1 + elseif c == ")" then close_count = close_count + 1 + end + end + return open_count > close_count + end + + -- Check if line ends an FB call + local function is_fb_call_end(line) + local trimmed = line:gsub("^%s+", ""):gsub("%s+$", "") + return trimmed:match(".*%)%s*;?%s*$") ~= nil and + not trimmed:match(".*%([^)]*%:?[=]") -- not still inside parameters + end + + -- Check if line is a continuation (ends with ,) + local function is_fb_call_continuation(line) + local trimmed = line:gsub("^%s+", ""):gsub("%s+$", "") + return trimmed:match(".*[,]%s*$") ~= nil + end + for i, line in ipairs(lines) do local trimmed = line:gsub("^%s+", ""):gsub("%s+$", "") + local original_indent = line:match("^%s*") -- Extract full keyword (handle compound keywords like END_IF, END_FUNCTION_BLOCK) local kw = trimmed:match("^(%w+_%w+)") or trimmed:match("^(%w+)") - -- Preserve empty lines and comments as-is - if trimmed == "" or trimmed:match("^//") or trimmed:match("^%(%*") or trimmed:match("^%*/") then + -- Check for FB call start (before comment handling) + -- Only detect if we're not already in an FB call + if not in_fb_call and is_fb_call_start(trimmed) then + -- Extract FB name length (without quotes) + 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_indent = indent_level + end + end + + -- Handle comments - re-indent them + if trimmed:match("^//") then + -- Line comment + table.insert(formatted_lines, get_indent() .. trimmed) + elseif trimmed:match("^%(%*") or trimmed:match("^%*%/") then + -- Block comment at start of line + table.insert(formatted_lines, get_indent() .. trimmed) + elseif trimmed == "" then + -- Preserve empty lines as-is table.insert(formatted_lines, line) else + -- Handle FB call continuation + local formatted_line + if in_fb_call then + -- Check if this ends the FB call + if not is_fb_call_inside(trimmed) then + formatted_line = get_continuation_indent() .. trimmed + in_fb_call = false + -- Check if this is a continuation line + elseif is_fb_call_continuation(trimmed) then + formatted_line = get_continuation_indent() .. trimmed + else + formatted_line = get_indent() .. trimmed + end + else + formatted_line = get_indent() .. trimmed + end + -- Check for BEGIN keyword (starts code section) if kw == "BEGIN" then in_code_section = true - table.insert(formatted_lines, get_indent() .. trimmed) + table.insert(formatted_lines, formatted_line) indent_level = 1 -- Check for VAR section start elseif is_var_keyword(kw) then in_var_section = true in_code_section = false indent_level = 0 - table.insert(formatted_lines, get_indent() .. trimmed) + table.insert(formatted_lines, formatted_line) indent_level = 1 -- Check for END_VAR elseif kw == "END_VAR" then in_var_section = false indent_level = 0 - table.insert(formatted_lines, get_indent() .. trimmed) + table.insert(formatted_lines, formatted_line) -- Check for block declaration enders elseif is_block_declaration_ender(kw) then indent_level = math.max(0, indent_level - 1) - local formatted_line = get_indent() .. trimmed if needs_semicolon(trimmed) then formatted_line = formatted_line .. ";" end table.insert(formatted_lines, formatted_line) -- Check for block declaration starters (FUNCTION, FUNCTION_BLOCK, etc.) elseif is_block_declaration(kw) then - table.insert(formatted_lines, get_indent() .. trimmed) + table.insert(formatted_lines, formatted_line) indent_level = 0 -- Check for block enders elseif is_block_ender(kw) then if kw == "END_IF" then if_base_level = nil end + in_fb_call = false -- Reset FB call state indent_level = math.max(0, indent_level - 1) - local formatted_line = get_indent() .. trimmed if needs_semicolon(trimmed) then formatted_line = formatted_line .. ";" end table.insert(formatted_lines, formatted_line) - -- Check for CASE label -- Check for CASE labels (like "1:") elseif is_case_label(trimmed) and prev_line_keyword == "CASE" then - -- CASE label stays at same indent as CASE (already increased) - local formatted_line = get_indent() .. trimmed table.insert(formatted_lines, formatted_line) - -- Increase for content after label indent_level = indent_level + 1 -- Check for same-level keywords (THEN, ELSE, DO, OF on their own line) elseif same_level_keywords[kw] then @@ -182,7 +281,7 @@ function M.format_document(content, options) if (kw == "ELSIF" or kw == "ELSE") and if_base_level then indent_level = if_base_level end - table.insert(formatted_lines, get_indent() .. trimmed) + table.insert(formatted_lines, formatted_line) -- After ELSE/ELSIF, increase indent for content if (kw == "ELSIF" or kw == "ELSE") and if_base_level then indent_level = indent_level + 1 @@ -193,13 +292,11 @@ function M.format_document(content, options) if kw == "IF" then if_base_level = indent_level end - table.insert(formatted_lines, get_indent() .. trimmed) - -- Always increase indent after block starters + table.insert(formatted_lines, formatted_line) indent_level = indent_level + 1 -- Regular line else - local formatted_line = get_indent() .. trimmed - if needs_semicolon(trimmed) then + if needs_semicolon(trimmed) and not in_fb_call then formatted_line = formatted_line .. ";" end table.insert(formatted_lines, formatted_line) From eb11380aea48146e8a2f83651a9b0e4359db1c44 Mon Sep 17 00:00:00 2001 From: Lazar Bulovan Date: Wed, 18 Feb 2026 14:50:49 +0100 Subject: [PATCH 04/11] fix(formatter): Fix FB call continuation indent - Use correct continuation indent formula (base + fb_name_length + 2) - Fix closing line of FB call to use continuation indent - Parameters on continuation lines are now aligned --- src/formatter.lua | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/formatter.lua b/src/formatter.lua index b4696e1..69f2473 100644 --- a/src/formatter.lua +++ b/src/formatter.lua @@ -78,8 +78,11 @@ function M.format_document(content, options) end local function get_continuation_indent() - -- Continuation is one additional indent level beyond current - return string.rep(indent_char, (indent_level + 1) * indent_size) + -- Continuation indent to align parameters + -- Aligns parameter name with position after FB name + opening paren + -- Formula: base indent + FB name length + 1 (for opening paren) + 1 (extra offset) + local base_indent = string.rep(indent_char, indent_level * indent_size) + return base_indent .. string.rep(" ", fb_name_length + 2) end local function get_keyword(line) @@ -218,15 +221,18 @@ function M.format_document(content, options) -- Handle FB call continuation local formatted_line if in_fb_call then - -- Check if this ends the FB call - if not is_fb_call_inside(trimmed) then + -- Check if line ends an FB call (has closing paren without opening) + local has_closing = trimmed:match(".*%)") + if has_closing and not trimmed:match("%(") then + -- This line closes the FB call formatted_line = get_continuation_indent() .. trimmed in_fb_call = false - -- Check if this is a continuation line + -- Check if this is a continuation line (ends with ,) elseif is_fb_call_continuation(trimmed) then formatted_line = get_continuation_indent() .. trimmed else - formatted_line = get_indent() .. trimmed + -- Still inside FB call but not a continuation + formatted_line = get_continuation_indent() .. trimmed end else formatted_line = get_indent() .. trimmed From 5b5d9c46f6abd92c3bfead6d109b79f541fdb002 Mon Sep 17 00:00:00 2001 From: Lazar Bulovan Date: Wed, 18 Feb 2026 15:05:13 +0100 Subject: [PATCH 05/11] fix(formatter): Fix FB call indentation logic - Track first line of FB call separately with fb_call_first_line flag - First line of FB call uses normal indent - Continuation lines use continuation indent (base + fb_name_length + 2) - Closing line uses normal indent - Parameters now align correctly --- src/formatter.lua | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/src/formatter.lua b/src/formatter.lua index 69f2473..00cdf01 100644 --- a/src/formatter.lua +++ b/src/formatter.lua @@ -72,6 +72,7 @@ function M.format_document(content, options) local in_fb_call = false local fb_call_indent = 0 local fb_name_length = 0 + local fb_call_first_line = false local function get_indent() return string.rep(indent_char, indent_level * indent_size) @@ -204,6 +205,7 @@ function M.format_document(content, options) fb_name_length = #fb_name in_fb_call = true fb_call_indent = indent_level + fb_call_first_line = true end end @@ -221,17 +223,28 @@ function M.format_document(content, options) -- Handle FB call continuation local formatted_line if in_fb_call then - -- Check if line ends an FB call (has closing paren without opening) + -- Check if this is the first line of the FB call + local is_first_line = fb_call_first_line + + -- Check if line ends an FB call (has closing paren) local has_closing = trimmed:match(".*%)") if has_closing and not trimmed:match("%(") then - -- This line closes the FB call - formatted_line = get_continuation_indent() .. trimmed + -- This line closes the FB call - use normal indent + formatted_line = get_indent() .. trimmed in_fb_call = false + fb_call_first_line = false -- Check if this is a continuation line (ends with ,) elseif is_fb_call_continuation(trimmed) then - formatted_line = get_continuation_indent() .. trimmed + if is_first_line then + -- First line of FB call - use normal indent + formatted_line = get_indent() .. trimmed + fb_call_first_line = false + else + -- Continuation line - use continuation indent + formatted_line = get_continuation_indent() .. trimmed + end else - -- Still inside FB call but not a continuation + -- Still inside FB call but not a continuation - use continuation indent formatted_line = get_continuation_indent() .. trimmed end else From 5bc6bb0750dff12ba042669bc5a5be96406e4579 Mon Sep 17 00:00:00 2001 From: Lazar Bulovan Date: Wed, 18 Feb 2026 15:16:16 +0100 Subject: [PATCH 06/11] fix(formatter): Keep END_IF at same indent level as content Per TIA Portal standard, END_IF stays at the same indent level as the code inside the IF block, rather than decreasing. This matches the reference file Em101Sequence.scl where nested END_IF statements are properly aligned. --- src/formatter.lua | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/formatter.lua b/src/formatter.lua index 00cdf01..dfd68ed 100644 --- a/src/formatter.lua +++ b/src/formatter.lua @@ -285,7 +285,11 @@ function M.format_document(content, options) if_base_level = nil end in_fb_call = false -- Reset FB call state - indent_level = math.max(0, indent_level - 1) + -- Don't decrease indent for END_IF - it stays at same level as content (per TIA Portal) + -- Only decrease for other enders like END_REGION, END_CASE, END_FOR, etc. + if kw ~= "END_IF" then + indent_level = math.max(0, indent_level - 1) + end if needs_semicolon(trimmed) then formatted_line = formatted_line .. ";" end From 25c306efee5a0622691b7cde7b250b23dc03362d Mon Sep 17 00:00:00 2001 From: Lazar Bulovan Date: Wed, 18 Feb 2026 15:26:05 +0100 Subject: [PATCH 07/11] fix(formatter): Handle THEN on separate line and multi-line conditions - Track when IF has THEN on separate line - Don't increase indent after IF until THEN is seen - Increase indent after THEN for content - Continuation lines in conditions stay at same level as IF - END_IF stays at same level as content (TIA Portal style) --- src/formatter.lua | 32 +++++++++++++++++++++++++++++--- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/src/formatter.lua b/src/formatter.lua index dfd68ed..70e6abc 100644 --- a/src/formatter.lua +++ b/src/formatter.lua @@ -67,6 +67,7 @@ function M.format_document(content, options) local in_var_section = false local in_code_section = false local prev_line_keyword = nil + local prev_line_had_then = true -- Track if previous line had THEN -- FB call continuation tracking local in_fb_call = false @@ -300,6 +301,11 @@ function M.format_document(content, options) indent_level = indent_level + 1 -- Check for same-level keywords (THEN, ELSE, DO, OF on their own line) elseif same_level_keywords[kw] then + -- For THEN on its own line (after IF without THEN on same line), increase indent for content + if kw == "THEN" then + -- Increase indent after THEN for content + indent_level = indent_level + 1 + end -- For ELSIF and ELSE, reset to IF base level if (kw == "ELSIF" or kw == "ELSE") and if_base_level then indent_level = if_base_level @@ -311,12 +317,28 @@ function M.format_document(content, options) end -- Check for block starters elseif is_block_starter(kw) then - -- Save base level for IF statements + -- Save base level for IF statements AFTER checking for THEN if kw == "IF" then - if_base_level = indent_level + -- Check if THEN is on the same line + if trimmed:match("THEN%s*$") then + if_base_level = indent_level + prev_line_had_then = true + indent_level = indent_level + 1 + else + -- THEN on separate line, don't increase yet + if_base_level = indent_level + prev_line_had_then = false + end + elseif kw == "THEN" then + -- THEN on its own line, increase for content + prev_line_had_then = true + indent_level = indent_level + 1 + else + -- For other block starters (FOR, WHILE, REGION, CASE), always increase + prev_line_had_then = true + indent_level = indent_level + 1 end table.insert(formatted_lines, formatted_line) - indent_level = indent_level + 1 -- Regular line else if needs_semicolon(trimmed) and not in_fb_call then @@ -327,6 +349,10 @@ function M.format_document(content, options) end prev_line_keyword = kw + -- Reset THEN tracking for next iteration (unless current line has THEN) + if kw ~= "THEN" then + prev_line_had_then = true + end end local result = table.concat(formatted_lines, "\n") From 2c4815baec5c81a38e2ac8dcd71b0347f1098b60 Mon Sep 17 00:00:00 2001 From: Lazar Bulovan Date: Wed, 18 Feb 2026 15:35:35 +0100 Subject: [PATCH 08/11] fix(formatter): Fix multi-line condition and END_IF indentation - Multi-line IF conditions: continuation lines at +1 from IF - END_IF now decreases indent (reverted previous wrong change) - Track multi-line condition state separately from THEN tracking - Now matches TIA Portal expected format --- src/formatter.lua | 37 ++++++++++++++++++++++--------------- 1 file changed, 22 insertions(+), 15 deletions(-) diff --git a/src/formatter.lua b/src/formatter.lua index 70e6abc..7c92e32 100644 --- a/src/formatter.lua +++ b/src/formatter.lua @@ -67,7 +67,7 @@ function M.format_document(content, options) local in_var_section = false local in_code_section = false local prev_line_keyword = nil - local prev_line_had_then = true -- Track if previous line had THEN + local in_multi_line_condition = false -- Track if we're in multi-line IF condition -- FB call continuation tracking local in_fb_call = false @@ -286,11 +286,7 @@ function M.format_document(content, options) if_base_level = nil end in_fb_call = false -- Reset FB call state - -- Don't decrease indent for END_IF - it stays at same level as content (per TIA Portal) - -- Only decrease for other enders like END_REGION, END_CASE, END_FOR, etc. - if kw ~= "END_IF" then - indent_level = math.max(0, indent_level - 1) - end + indent_level = math.max(0, indent_level - 1) if needs_semicolon(trimmed) then formatted_line = formatted_line .. ";" end @@ -322,25 +318,40 @@ function M.format_document(content, options) -- Check if THEN is on the same line if trimmed:match("THEN%s*$") then if_base_level = indent_level - prev_line_had_then = true + in_multi_line_condition = false indent_level = indent_level + 1 else -- THEN on separate line, don't increase yet if_base_level = indent_level - prev_line_had_then = false + in_multi_line_condition = true end elseif kw == "THEN" then - -- THEN on its own line, increase for content - prev_line_had_then = true + -- THEN on its own line, increase for content and exit multi-line condition + in_multi_line_condition = false indent_level = indent_level + 1 else -- For other block starters (FOR, WHILE, REGION, CASE), always increase - prev_line_had_then = true + in_multi_line_condition = false indent_level = indent_level + 1 end table.insert(formatted_lines, formatted_line) -- Regular line else + -- Handle multi-line condition continuation + local use_multi_line_indent = false + if in_multi_line_condition and kw ~= "THEN" then + -- This is a continuation of a multi-line IF condition + -- Use +1 indent from IF level + use_multi_line_indent = true + in_multi_line_condition = false -- Exit multi-line mode after first continuation + end + + if use_multi_line_indent then + formatted_line = string.rep(indent_char, (indent_level + 1) * indent_size) .. trimmed + else + formatted_line = get_indent() .. trimmed + end + if needs_semicolon(trimmed) and not in_fb_call then formatted_line = formatted_line .. ";" end @@ -349,10 +360,6 @@ function M.format_document(content, options) end prev_line_keyword = kw - -- Reset THEN tracking for next iteration (unless current line has THEN) - if kw ~= "THEN" then - prev_line_had_then = true - end end local result = table.concat(formatted_lines, "\n") From 37ab917422f6470cbea6a0b2795d20632611421a Mon Sep 17 00:00:00 2001 From: Lazar Bulovan Date: Wed, 18 Feb 2026 19:08:54 +0100 Subject: [PATCH 09/11] Rewrite formatter with stack-based approach for proper nested structure handling --- src/formatter.lua | 612 +++++++++++++++++++++++++--------------------- 1 file changed, 333 insertions(+), 279 deletions(-) diff --git a/src/formatter.lua b/src/formatter.lua index 7c92e32..f4bb454 100644 --- a/src/formatter.lua +++ b/src/formatter.lua @@ -1,5 +1,5 @@ -- SCL Formatter - Document formatting for the LSP server --- Conforms to TIA Portal SCL formatting standards +-- Stack-based implementation for proper nested structure handling local M = {} local function position(line, character) @@ -13,355 +13,409 @@ local function range(start_line, start_char, end_line, end_char) } end --- Keywords that start a code block (increase indent AFTER) -local block_starters = { - IF = true, FOR = true, WHILE = true, REPEAT = true, - CASE = true, REGION = true, +-- 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", + ["VAR_IN_OUT"] = "END_VAR", + ["VAR_TEMP"] = "END_VAR", + ["VAR_CONSTANT"] = "END_VAR", + ["VAR_RETAIN"] = "END_VAR", + ["VAR_NON_RETAIN"] = "END_VAR", } --- Keywords that stay on same level (don't change indent) -local same_level_keywords = { - THEN = true, ELSE = true, ELSIF = true, DO = true, OF = true, +-- Keywords that don't change indent level themselves (handled specially) +local SAME_LEVEL = { + ["THEN"] = true, + ["ELSE"] = true, + ["ELSIF"] = true, + ["DO"] = true, + ["OF"] = true, + ["BEGIN"] = true, } --- Keywords that end a code block (decrease indent BEFORE) -local block_enders = { - END_IF = true, END_FOR = true, END_WHILE = true, END_REPEAT = true, - END_CASE = true, END_REGION = true, +-- Control structure enders that need semicolons +local NEEDS_SEMICOLON = { + ["END_IF"] = true, + ["END_FOR"] = true, + ["END_WHILE"] = true, + ["END_REPEAT"] = true, + ["END_CASE"] = true, + ["END_REGION"] = true, } --- Block declaration keywords -local block_declarations = { - FUNCTION = true, FUNCTION_BLOCK = true, ORGANIZATION_BLOCK = true, - TYPE = true, STRUCT = true, +-- VAR keywords for special handling +local VAR_KEYWORDS = { + ["VAR"] = true, + ["VAR_INPUT"] = true, + ["VAR_OUTPUT"] = true, + ["VAR_IN_OUT"] = true, + ["VAR_TEMP"] = true, + ["VAR_CONSTANT"] = true, + ["VAR_RETAIN"] = true, + ["VAR_NON_RETAIN"] = true, } --- End block declarations -local block_declaration_enders = { - END_FUNCTION = true, END_FUNCTION_BLOCK = true, END_ORGANIZATION_BLOCK = true, - END_TYPE = true, END_STRUCT = true, -} - --- VAR section keywords -local var_keywords = { - VAR = true, VAR_INPUT = true, VAR_OUTPUT = true, VAR_IN_OUT = true, - VAR_TEMP = true, VAR_CONSTANT = true, VAR_RETAIN = true, VAR_NON_RETAIN = true, +-- Declaration keywords (reset indent) +local DECLARATIONS = { + ["FUNCTION"] = true, + ["FUNCTION_BLOCK"] = true, + ["ORGANIZATION_BLOCK"] = true, + ["TYPE"] = true, + ["STRUCT"] = true, } function M.format_document(content, options) options = options or {} - local indent_char = options.insertSpaces and " " or "\t" + 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" + -- Parse content into lines local lines = {} for line in content:gmatch("([^\n]*)\n") do table.insert(lines, line) end - if #lines == 0 or content:sub(-1) ~= "\n" then - table.insert(lines, "") + local last_line = content:match("([^\n]+)$") + if last_line and last_line ~= "" then + table.insert(lines, last_line) + end + if #lines == 0 then + return { { range = range(0, 0, 0, 0), newText = "" } } end + -- Stack of active blocks + -- Each entry: { type = "IF", indent = 1, is_case = false } + local stack = {} local formatted_lines = {} - local indent_level = 0 - local if_base_level = nil - local in_var_section = false - local in_code_section = false - local prev_line_keyword = nil - local in_multi_line_condition = false -- Track if we're in multi-line IF condition - - -- FB call continuation tracking + + -- FB call state local in_fb_call = false - local fb_call_indent = 0 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() - return string.rep(indent_char, indent_level * indent_size) - end - - local function get_continuation_indent() - -- Continuation indent to align parameters - -- Aligns parameter name with position after FB name + opening paren - -- Formula: base indent + FB name length + 1 (for opening paren) + 1 (extra offset) - local base_indent = string.rep(indent_char, indent_level * indent_size) - return base_indent .. string.rep(" ", fb_name_length + 2) - end - - local function get_keyword(line) - local trimmed = line:gsub("^%s+", ""):gsub("%s+$", "") - local kw = trimmed:match("^(%w+)") - return kw, trimmed - end - - local function is_block_starter(kw) - return kw and block_starters[kw] - end - - local function is_block_ender(kw) - return kw and block_enders[kw] - end - - local function is_var_keyword(kw) - return kw and var_keywords[kw] - end - - local function is_block_declaration(kw) - return kw and block_declarations[kw] - end - - local function is_block_declaration_ender(kw) - return kw and block_declaration_enders[kw] - end - - local function needs_semicolon(trimmed) - local kw = trimmed:match("^(%w+_%w+)") or trimmed:match("^(%w+)") - if not kw then return false end - local needs_end = { - END_IF = true, END_FOR = true, END_WHILE = true, END_REPEAT = true, - END_CASE = true, END_REGION = true, - END_FUNCTION = true, END_FUNCTION_BLOCK = true, END_ORGANIZATION_BLOCK = true, - END_TYPE = true, END_STRUCT = true, - } - if needs_end[kw] then - local last_char = trimmed:sub(-1) - return last_char ~= ";" and last_char ~= "(" + local level = 0 + for _, block in ipairs(stack) do + if not block.no_indent then + level = level + 1 + end end - return false + 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) + end + + -- Helper: check if line is a case label local function is_case_label(trimmed) - return trimmed:match("^%w+%s*:") ~= nil + return trimmed:match("^[%w_#]+%s*:%s*$") ~= nil end - -- Check if line is an FB/function call start - -- Check if line is an FB/function call start - -- Matches: #fbname( or "FBName"( - but NOT if it has := before the ( + -- Helper: check if line starts FB call local function is_fb_call_start(line) local trimmed = line:gsub("^%s+", "") - -- Must start with # or " - local has_fb_syntax = trimmed:match("^#%w+") or trimmed:match('^"') - if not has_fb_syntax then return false end - - -- Check if there's a ( in the line - local paren_pos = trimmed:find("%(") - if not paren_pos then return false end -- No parentheses, not an FB call - - -- Check if := appears BEFORE the ( (which would make it an assignment, not an FB call) - local assign_pos = trimmed:find(":=") - if assign_pos and assign_pos < paren_pos then - return false -- Has := before (, it's an assignment + if not (trimmed:match("^#%w+") or trimmed:match('^"')) then + return false end - - -- Check if it has unclosed parentheses - local open_count = 0 - local close_count = 0 + 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 + elseif c == ")" then close_count = close_count + 1 end end return open_count > 0 and close_count < open_count end - -- Check if line is inside an FB call (has unclosed parentheses) - local function is_fb_call_inside(line) - local open_count = 0 - local close_count = 0 + -- 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 local c = line:sub(i, i) if c == "(" then open_count = open_count + 1 - elseif c == ")" then close_count = close_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 + end + + -- Helper: extract keyword from line + local function get_keyword(trimmed) + return trimmed:match("^(%w+_%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 + return stack[i], i end end - return open_count > close_count + return nil, nil end - -- Check if line ends an FB call - local function is_fb_call_end(line) - local trimmed = line:gsub("^%s+", ""):gsub("%s+$", "") - return trimmed:match(".*%)%s*;?%s*$") ~= nil and - not trimmed:match(".*%([^)]*%:?[=]") -- not still inside parameters + -- Helper: pop blocks until matching ender found + local function pop_to_ender(ender_type) + while #stack > 0 do + local top = stack[#stack] + table.remove(stack) + local expected_ender = BLOCKS[top.type] + if expected_ender == ender_type then + return true + end + end + return false end - -- Check if line is a continuation (ends with ,) - local function is_fb_call_continuation(line) + -- Process each line + for line_num, line in ipairs(lines) do local trimmed = line:gsub("^%s+", ""):gsub("%s+$", "") - return trimmed:match(".*[,]%s*$") ~= nil - end + local kw = get_keyword(trimmed) - for i, line in ipairs(lines) do - local trimmed = line:gsub("^%s+", ""):gsub("%s+$", "") - local original_indent = line:match("^%s*") - - -- Extract full keyword (handle compound keywords like END_IF, END_FUNCTION_BLOCK) - local kw = trimmed:match("^(%w+_%w+)") or trimmed:match("^(%w+)") + -- Handle empty lines (preserve as-is) + if trimmed == "" then + table.insert(formatted_lines, "") + goto continue + end - -- Check for FB call start (before comment handling) - -- Only detect if we're not already in an FB call + -- Handle comments (re-indent with current level) + if trimmed:match("^//") or trimmed:match("^%(") then + table.insert(formatted_lines, get_indent() .. trimmed) + goto continue + end + + -- Check for FB call start if not in_fb_call and is_fb_call_start(trimmed) then - -- Extract FB name length (without quotes) 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_indent = indent_level 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 comments - re-indent them - if trimmed:match("^//") then - -- Line comment - table.insert(formatted_lines, get_indent() .. trimmed) - elseif trimmed:match("^%(%*") or trimmed:match("^%*%/") then - -- Block comment at start of line - table.insert(formatted_lines, get_indent() .. trimmed) - elseif trimmed == "" then - -- Preserve empty lines as-is - table.insert(formatted_lines, line) - else - -- Handle FB call continuation - local formatted_line - if in_fb_call then - -- Check if this is the first line of the FB call - local is_first_line = fb_call_first_line - - -- Check if line ends an FB call (has closing paren) - local has_closing = trimmed:match(".*%)") - if has_closing and not trimmed:match("%(") then - -- This line closes the FB call - use normal indent - formatted_line = get_indent() .. trimmed - in_fb_call = false - fb_call_first_line = false - -- Check if this is a continuation line (ends with ,) - elseif is_fb_call_continuation(trimmed) then - if is_first_line then - -- First line of FB call - use normal indent - formatted_line = get_indent() .. trimmed - fb_call_first_line = false - else - -- Continuation line - use continuation indent - formatted_line = get_continuation_indent() .. trimmed - end - else - -- Still inside FB call but not a continuation - use continuation indent - formatted_line = get_continuation_indent() .. trimmed - end - else - formatted_line = get_indent() .. trimmed - 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) - -- Check for BEGIN keyword (starts code section) - if kw == "BEGIN" then - in_code_section = true - table.insert(formatted_lines, formatted_line) - indent_level = 1 - -- Check for VAR section start - elseif is_var_keyword(kw) then - in_var_section = true - in_code_section = false - indent_level = 0 - table.insert(formatted_lines, formatted_line) - indent_level = 1 - -- Check for END_VAR - elseif kw == "END_VAR" then - in_var_section = false - indent_level = 0 - table.insert(formatted_lines, formatted_line) - -- Check for block declaration enders - elseif is_block_declaration_ender(kw) then - indent_level = math.max(0, indent_level - 1) - if needs_semicolon(trimmed) then - formatted_line = formatted_line .. ";" - end - table.insert(formatted_lines, formatted_line) - -- Check for block declaration starters (FUNCTION, FUNCTION_BLOCK, etc.) - elseif is_block_declaration(kw) then - table.insert(formatted_lines, formatted_line) - indent_level = 0 - -- Check for block enders - elseif is_block_ender(kw) then - if kw == "END_IF" then - if_base_level = nil - end - in_fb_call = false -- Reset FB call state - indent_level = math.max(0, indent_level - 1) - if needs_semicolon(trimmed) then - formatted_line = formatted_line .. ";" - end - table.insert(formatted_lines, formatted_line) - -- Check for CASE labels (like "1:") - elseif is_case_label(trimmed) and prev_line_keyword == "CASE" then - table.insert(formatted_lines, formatted_line) - indent_level = indent_level + 1 - -- Check for same-level keywords (THEN, ELSE, DO, OF on their own line) - elseif same_level_keywords[kw] then - -- For THEN on its own line (after IF without THEN on same line), increase indent for content - if kw == "THEN" then - -- Increase indent after THEN for content - indent_level = indent_level + 1 - end - -- For ELSIF and ELSE, reset to IF base level - if (kw == "ELSIF" or kw == "ELSE") and if_base_level then - indent_level = if_base_level - end - table.insert(formatted_lines, formatted_line) - -- After ELSE/ELSIF, increase indent for content - if (kw == "ELSIF" or kw == "ELSE") and if_base_level then - indent_level = indent_level + 1 - end - -- Check for block starters - elseif is_block_starter(kw) then - -- Save base level for IF statements AFTER checking for THEN - if kw == "IF" then - -- Check if THEN is on the same line - if trimmed:match("THEN%s*$") then - if_base_level = indent_level - in_multi_line_condition = false - indent_level = indent_level + 1 - else - -- THEN on separate line, don't increase yet - if_base_level = indent_level - in_multi_line_condition = true - end - elseif kw == "THEN" then - -- THEN on its own line, increase for content and exit multi-line condition - in_multi_line_condition = false - indent_level = indent_level + 1 - else - -- For other block starters (FOR, WHILE, REGION, CASE), always increase - in_multi_line_condition = false - indent_level = indent_level + 1 - end - table.insert(formatted_lines, formatted_line) - -- Regular line + 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 - -- Handle multi-line condition continuation - local use_multi_line_indent = false - if in_multi_line_condition and kw ~= "THEN" then - -- This is a continuation of a multi-line IF condition - -- Use +1 indent from IF level - use_multi_line_indent = true - in_multi_line_condition = false -- Exit multi-line mode after first continuation + formatted = get_continuation_indent() .. trimmed + 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 + stack = {} + table.insert(formatted_lines, trimmed) + goto continue end - - if use_multi_line_indent then - formatted_line = string.rep(indent_char, (indent_level + 1) * indent_size) .. trimmed - else - formatted_line = get_indent() .. trimmed - end - - if needs_semicolon(trimmed) and not in_fb_call then - formatted_line = formatted_line .. ";" - end - table.insert(formatted_lines, formatted_line) end end - prev_line_keyword = kw + -- 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 + table.insert(formatted_lines, get_indent() .. trimmed) + table.insert(stack, { type = kw, indent = #stack, is_var = true }) + 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 + table.insert(formatted_lines, get_indent() .. trimmed) + 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 .. ";" + end + table.insert(formatted_lines, formatted) + goto continue + end + 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 + if_block = stack[i] + break + end + end + if if_block then + formatted = string.rep(indent_char, if_block.indent) .. trimmed + else + formatted = get_indent() .. trimmed + end + table.insert(formatted_lines, formatted) + -- 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 + if_block = stack[i] + break + end + end + if if_block then + formatted = string.rep(indent_char, if_block.indent) .. trimmed + else + formatted = get_indent() .. trimmed + end + table.insert(formatted_lines, formatted) + -- Push new content block + table.insert(stack, { type = "THEN_CONTENT", indent = #stack, no_indent = true }) + else + formatted = get_indent() .. trimmed + table.insert(formatted_lines, formatted) + end + 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) + end + 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 + if not block.no_indent then + current_level = current_level + 1 + end + end + local formatted = string.rep(indent_char, current_level) .. trimmed + table.insert(formatted_lines, formatted) + table.insert(stack, { type = kw, indent = current_level }) + goto continue + end + + -- Regular line + 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 + table.insert(formatted_lines, formatted) + + ::continue:: end + -- Join lines local result = table.concat(formatted_lines, "\n") if content:sub(-1) == "\n" then result = result .. "\n" From 978e46ba454d81ffdc78a6b9b03924fa2569e69d Mon Sep 17 00:00:00 2001 From: Lazar Bulovan Date: Thu, 19 Feb 2026 10:12:58 +0100 Subject: [PATCH 10/11] 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) From 374636d2f4baad1d306644bdaa4d3fdf6a2b91bd Mon Sep 17 00:00:00 2001 From: Lazar Bulovan Date: Thu, 19 Feb 2026 10:13:43 +0100 Subject: [PATCH 11/11] docs: update README with attribute block toggle feature Add documentation for new attribute block toggle feature including: - New commands: SCLToggleAttrBlock, SCLExpandAllAttrBlocks, SCLCollapseAllAttrBlocks - New keybindings: xa, xae, xac - Feature description and usage example --- README.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/README.md b/README.md index 98b9e42..77f7210 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,7 @@ A comprehensive Neovim/LazyVim plugin providing **Language Server Protocol (LSP) | **Workspace UDT Scanner** | Scans project for user-defined types (.udt files) | | **Workspace Global DB Scanner** | Scans project for global data blocks (.db files) | | **blink.cmp Integration** | Smart completion source for blink.cmp | +| **Attribute Block Toggle** | Interactive expand/collapse of `{...}` blocks with `xa` | ## Installation @@ -116,6 +117,9 @@ require("scl_lsp").setup({ | `:SCLMultilineParams` | Fill multiline parameters for FB/Function call | | `:LspSCLFormat` | Format current SCL file | | `:SCLGeneratePlcJson` | Generate plc.data.json from data_types/ | +| `:SCLToggleAttrBlock` | Toggle attribute block under cursor | +| `:SCLExpandAllAttrBlocks` | Expand all `{...}` blocks in buffer | +| `:SCLCollapseAllAttrBlocks` | Collapse all matching attribute blocks | ## Keybindings @@ -127,9 +131,32 @@ require("scl_lsp").setup({ | `w` | Workspace Symbols | | `mp` | Fill multiline FB/Function parameters (insert mode) | | `` | Jump to next parameter (insert mode) | +| `xa` | Toggle attribute block under cursor | +| `xae` | Expand all attribute blocks | +| `xac` | Collapse all attribute blocks | | `fw` | Workspace Symbols (Telescope) | | `ca` | Code Actions | +## Attribute Block Toggle + +Interactive expand/collapse of SCL variable attribute blocks: + +```scl +-- Before: Verbose attributes +statCounter{EXTERNALACCESSIBLE := 'false'; EXTERNALVISIBLE := 'false'} : Int; + +-- After: Collapsed +statCounter{...} : Int; +``` + +**Usage:** +- Place cursor inside any `{...}` block +- Press `xa` to toggle expand/collapse +- Use `xae` to expand all blocks in buffer +- Use `xac` to collapse all blocks + +The formatter also supports automatic collapsing via `collapseAttributes` option. + ## External UDT Support ### Option 1: Create `.plc.json`