10 Commits
Author SHA1 Message Date
lazar b4293b4f30 formatter: add DATA_BLOCK to BLOCKS + DECLARATIONS
Without this, DATA_BLOCK wasn't recognized as a declaration block,
so the stack was never populated for DB files. END_DATA_BLOCK fell
through to regular-line handling and got a tab prefix (from body
get_indent()), while DATA_BLOCK itself was flush-left.

Added DATA_BLOCK to both BLOCKS (ender = END_DATA_BLOCK) and
DECLARATIONS so it gets the same treatment as FUNCTION_BLOCK:
stack cleared, no_indent=true, and END_DATA_BLOCK handled by the
declaration ender path (flush-left output).
2026-07-24 11:31:53 +02:00
lazar 6fbce2af6b formatter: fix indent to match TIA Portal output
- Strip UTF-8 BOM so FUNCTION_BLOCK is correctly detected
- Add no_indent=true on DECLARATIONS blocks so they don't inflate body indent
- Fix is_case_label to match quoted strings (was using \s instead of %s)
- Push CASE_CONTENT block with label so content inside case labels gets
  proper nesting (one extra level beyond the label)
- All 38 formatter tests pass
- LLT_EquipmentManager.scl now matches TIA Portal export exactly
2026-07-24 10:48:44 +02:00
lazar 909354e71a fix: always use tabs for formatting, ignore editor insertSpaces 2026-07-24 10:03:25 +02:00
lazar d24e83a1a7 fix: register FBs with only VAR/TEMP as types + add SCLRescanWorkspaceTypes command 2026-07-23 13:55:07 +02:00
lazar ebdc96316a fix: skip commented lines in undefined variable diagnostic 2026-07-23 13:13:04 +02:00
lazar 3ca63d278b fix: . trigger branch now takes priority over completed # prefix
When the user has typed something like #instTestTimer., has_hash_prefix
was true and the # trigger branch ran instead of the . branch, so dot
completion (IN/PT/Q/ET) never appeared. Changed the condition from
'if trigger == "#" or has_hash_prefix' to 'if trigger == "#"' so
the . branch always wins when trigger is ..
2026-07-22 20:14:57 +02:00
lazar 8b400054c6 fix: . trigger prefix extraction broken when . not yet in document
When nvim-cmp sends completion before didChange commits the ., the
cursor col is at the word end, but the old code always scanned from
col-1, cutting off the variable's last character (instTestTime vs
instTestTimer). Now checks if the character at col is . or empty;
if so, scans from col-1; otherwise scans from col.
2026-07-22 17:55:52 +02:00
lazar cce4059b7e fix: also warn on unassigned input params + collapse all-unassigned calls
- SCL006 now fires for both unassigned inputs (IN := <-,) and outputs (Q =>)
- Formatter removes both IN := and Q => when no value follows
- When ALL params are unassigned, the call collapses to name()
2026-07-22 17:48:31 +02:00
lazar 5205819201 feat: remove unassigned output params in formatter + SCL006 diagnostic
- Formatter now strips unassigned output params (e.g. Q =>, ET =>)
  from FB/FC calls during formatting.
- Diagnostics now produce SCL006 warning for unassigned output params.
- Also converted src/ symlinks for diagnostics.lua and formatter.lua
  in mason package so all three files stay in sync.
2026-07-22 17:43:18 +02:00
lazar 73735192bd fix: skip number literal hashes in SCL001 diagnostic
16#FFFF (hex), 2#1010 (binary), 8#777 (octal) are TIA Portal
base-specific number literals, not hash-prefixed variables.
Check if the character before # is a digit to distinguish them.
2026-07-22 17:10:05 +02:00
4 changed files with 153 additions and 76 deletions
+31 -1
View File
@@ -138,12 +138,21 @@ function M.validate_diagnostics(content, workspace_types, root_dir)
return prefix == "T" or prefix == "D" or prefix == "S" return prefix == "T" or prefix == "D" or prefix == "S"
end end
-- Check if hash is part of a base-specific number literal like 16#FFFF, 2#1010
local function is_number_literal_at(line, match_start)
if not line or not match_start then return false end
if match_start <= 1 then return false end
local before = line:sub(match_start - 1, match_start - 1)
return before:match("%d") ~= nil
end
if not in_var_section then if not in_var_section then
if not (trimmed:match("^%s*//") or trimmed:match("^%s*%(%*")) then
local hash_vars = {} local hash_vars = {}
for var in line:gmatch("#[%w_]+") do for var in line:gmatch("#[%w_]+") do
local var_name = var:sub(2) local var_name = var:sub(2)
local match_start = line:find(var, 1, true) local match_start = line:find(var, 1, true)
if not is_iec_literal_at(line, match_start) then if not is_iec_literal_at(line, match_start) and not is_number_literal_at(line, match_start) then
hash_vars[var_name] = true hash_vars[var_name] = true
end end
end end
@@ -163,6 +172,7 @@ function M.validate_diagnostics(content, workspace_types, root_dir)
end end
end end
end end
end
if in_var_section and (trimmed:match("^[%w_]+%s*%b{}%s*:") or trimmed:match("^[%w_]+%s*:")) then 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_name = trimmed:match("^([%w_]+)%s*%b{}%s*:") or trimmed:match("^([%w_]+)%s*:")
@@ -246,6 +256,26 @@ function M.validate_diagnostics(content, workspace_types, root_dir)
if trimmed:match("^%s*//") or trimmed:match("^%s*%(%*") then if trimmed:match("^%s*//") or trimmed:match("^%s*%(%*") then
-- Skip comments -- Skip comments
else else
-- Check for unassigned parameters in FB/FC calls
for unassigned in line:gmatch("([%w_]+)%s*:=%s*[,%)]") do
table.insert(diagnostics, {
range = range_to_lsp(line_num, line:find(unassigned, 1, true) - 1, line_num, line:find(unassigned, 1, true) + #unassigned),
severity = DiagnosticSeverity.Warning,
message = string.format("Unassigned input parameter: %s :=", unassigned),
code = "SCL006",
source = "tia_lsp",
})
end
for unassigned in line:gmatch("([%w_]+)%s*=>%s*[,%)]") do
table.insert(diagnostics, {
range = range_to_lsp(line_num, line:find(unassigned, 1, true) - 1, line_num, line:find(unassigned, 1, true) + #unassigned),
severity = DiagnosticSeverity.Warning,
message = string.format("Unassigned output parameter: %s =>", unassigned),
code = "SCL006",
source = "tia_lsp",
})
end
for assignment in line:gmatch("#[%w_]+%s*:=") do for assignment in line:gmatch("#[%w_]+%s*:=") do
local var_name = assignment:match("#([%w_]+)") local var_name = assignment:match("#([%w_]+)")
if var_name and in_var_section then if var_name and in_var_section then
+59 -28
View File
@@ -43,6 +43,7 @@ local BLOCKS = {
["FUNCTION"] = "END_FUNCTION", ["FUNCTION"] = "END_FUNCTION",
["FUNCTION_BLOCK"] = "END_FUNCTION_BLOCK", ["FUNCTION_BLOCK"] = "END_FUNCTION_BLOCK",
["ORGANIZATION_BLOCK"] = "END_ORGANIZATION_BLOCK", ["ORGANIZATION_BLOCK"] = "END_ORGANIZATION_BLOCK",
["DATA_BLOCK"] = "END_DATA_BLOCK",
["TYPE"] = "END_TYPE", ["TYPE"] = "END_TYPE",
["STRUCT"] = "END_STRUCT", ["STRUCT"] = "END_STRUCT",
["VAR"] = "END_VAR", ["VAR"] = "END_VAR",
@@ -72,7 +73,6 @@ local NEEDS_SEMICOLON = {
["END_WHILE"] = true, ["END_WHILE"] = true,
["END_REPEAT"] = true, ["END_REPEAT"] = true,
["END_CASE"] = true, ["END_CASE"] = true,
["END_REGION"] = true,
} }
local VAR_KEYWORDS = { local VAR_KEYWORDS = {
@@ -92,6 +92,7 @@ local DECLARATIONS = {
["FUNCTION"] = true, ["FUNCTION"] = true,
["FUNCTION_BLOCK"] = true, ["FUNCTION_BLOCK"] = true,
["ORGANIZATION_BLOCK"] = true, ["ORGANIZATION_BLOCK"] = true,
["DATA_BLOCK"] = true,
["TYPE"] = true, ["TYPE"] = true,
["STRUCT"] = true, ["STRUCT"] = true,
} }
@@ -111,9 +112,6 @@ local CONTINUATION_OPERATORS = {
--- @return table Array of TextEdit objects with range and newText --- @return table Array of TextEdit objects with range and newText
function M.format_document(content, options) function M.format_document(content, options)
options = options or {} options = options or {}
local use_spaces = options.insertSpaces or false
local indent_size = options.tabSize or 1
local indent_char = use_spaces and string.rep(" ", indent_size) or "\t"
local collapse_attributes = options.collapseAttributes local collapse_attributes = options.collapseAttributes
if collapse_attributes == nil then if collapse_attributes == nil then
@@ -127,6 +125,11 @@ function M.format_document(content, options)
end end
end end
local bom = string.char(239, 187, 191)
if content:sub(1, 3) == bom then
content = content:sub(4)
end
local lines = {} local lines = {}
for line in content:gmatch("([^\n]*)\n") do for line in content:gmatch("([^\n]*)\n") do
table.insert(lines, line) table.insert(lines, line)
@@ -141,15 +144,29 @@ function M.format_document(content, options)
local stack = {} local stack = {}
local formatted_lines = {} local formatted_lines = {}
local in_code_body = false
local in_var_block = false
local function get_indent() local function get_indent()
if in_code_body then
local level = 0 local level = 0
for _, block in ipairs(stack) do for _, block in ipairs(stack) do
if not block.no_indent then if not block.no_indent then
level = level + 1 level = level + 1
end end
end end
return string.rep(indent_char, level) return "\t" .. string.rep(" ", level)
else
if in_var_block then
return " "
else
return ""
end
end
end
local function get_code_indent(level)
return "\t" .. string.rep(" ", level)
end end
local function is_assignment_continuation(trimmed) local function is_assignment_continuation(trimmed)
@@ -261,6 +278,15 @@ function M.format_document(content, options)
table.insert(params, last_param) table.insert(params, last_param)
end end
-- Remove unassigned parameters (e.g. "IN :=", "Q =>", "ET =>")
local filtered = {}
for _, p in ipairs(params) do
if not p:match("^[%w_]+%s*:=%s*$") and not p:match("^[%w_]+%s*=>%s*$") then
table.insert(filtered, p)
end
end
params = filtered
if #params == 0 then if #params == 0 then
return { base_indent .. fb_name .. "();" } return { base_indent .. fb_name .. "();" }
end end
@@ -276,7 +302,7 @@ function M.format_document(content, options)
end end
local function is_case_label(trimmed) local function is_case_label(trimmed)
return trimmed:match("^[%w_#]+%s*:%s*$") ~= nil return trimmed:match("^\"[^\"]+\"%s*:%s*$") ~= nil or trimmed:match("^[%w_#]+%s*:%s*$") ~= nil
end end
local function extract_variable_name(line) local function extract_variable_name(line)
@@ -489,7 +515,7 @@ function M.format_document(content, options)
if DECLARATIONS[kw] then if DECLARATIONS[kw] then
stack = {} stack = {}
table.insert(formatted_lines, trimmed) table.insert(formatted_lines, trimmed)
table.insert(stack, { type = kw, indent = 0, is_declaration = true }) table.insert(stack, { type = kw, indent = 0, is_declaration = true, no_indent = true })
goto continue goto continue
end end
@@ -507,14 +533,14 @@ function M.format_document(content, options)
while #stack > 0 and VAR_KEYWORDS[stack[#stack].type] do while #stack > 0 and VAR_KEYWORDS[stack[#stack].type] do
table.remove(stack) table.remove(stack)
end end
table.insert(formatted_lines, get_indent() .. trimmed) in_var_block = false
in_code_body = true
table.insert(formatted_lines, trimmed)
goto continue goto continue
end end
if in_code_body then
if VAR_KEYWORDS[kw] then if VAR_KEYWORDS[kw] then
while #stack > 0 and not stack[#stack].is_declaration do
table.remove(stack)
end
table.insert(formatted_lines, get_indent() .. trimmed) table.insert(formatted_lines, get_indent() .. trimmed)
table.insert(stack, { type = kw, indent = #stack, is_var = true }) table.insert(stack, { type = kw, indent = #stack, is_var = true })
goto continue goto continue
@@ -527,6 +553,19 @@ function M.format_document(content, options)
table.insert(formatted_lines, get_indent() .. trimmed) table.insert(formatted_lines, get_indent() .. trimmed)
goto continue goto continue
end end
else
if VAR_KEYWORDS[kw] then
in_var_block = true
table.insert(formatted_lines, trimmed)
goto continue
end
if kw == "END_VAR" then
in_var_block = false
table.insert(formatted_lines, trimmed)
goto continue
end
end
if kw and BLOCKS[kw] == nil then if kw and BLOCKS[kw] == nil then
for starter, ender in pairs(BLOCKS) do for starter, ender in pairs(BLOCKS) do
@@ -556,7 +595,7 @@ function M.format_document(content, options)
break break
end end
end end
then_indent = if_block and string.rep(indent_char, if_block.indent) or get_indent() then_indent = if_block and get_code_indent(if_block.indent) or get_indent()
end end
table.insert(formatted_lines, then_indent .. trimmed) table.insert(formatted_lines, then_indent .. trimmed)
table.insert(stack, { type = "THEN_CONTENT", indent = #stack, no_indent = true }) table.insert(stack, { type = "THEN_CONTENT", indent = #stack, no_indent = true })
@@ -573,7 +612,7 @@ function M.format_document(content, options)
end end
end end
if if_block then if if_block then
formatted = string.rep(indent_char, if_block.indent) .. trimmed formatted = get_code_indent(if_block.indent) .. trimmed
else else
formatted = get_indent() .. trimmed formatted = get_indent() .. trimmed
end end
@@ -582,7 +621,7 @@ function M.format_document(content, options)
-- Check if this is a multi-line condition (doesn't end with THEN on same line) -- Check if this is a multi-line condition (doesn't end with THEN on same line)
if not trimmed:match("THEN%s*$") then if not trimmed:match("THEN%s*$") then
in_condition = true in_condition = true
condition_indent = string.rep(indent_char, (if_block and if_block.indent or 0) + 1) condition_indent = get_code_indent((if_block and if_block.indent or 0) + 1)
end end
else else
formatted = get_indent() .. trimmed formatted = get_indent() .. trimmed
@@ -597,9 +636,10 @@ function M.format_document(content, options)
while #stack > case_idx do while #stack > case_idx do
table.remove(stack) table.remove(stack)
end end
local label_indent = string.rep(indent_char, case_block.indent + 1) local label_indent = get_code_indent(case_block.indent + 1)
table.insert(formatted_lines, label_indent .. trimmed) table.insert(formatted_lines, label_indent .. trimmed)
table.insert(stack, { type = "CASE_LABEL", indent = case_block.indent + 1, no_indent = true }) table.insert(stack, { type = "CASE_LABEL", indent = case_block.indent + 1, no_indent = true })
table.insert(stack, { type = "CASE_CONTENT", indent = case_block.indent + 2 })
else else
table.insert(formatted_lines, get_indent() .. trimmed) table.insert(formatted_lines, get_indent() .. trimmed)
end end
@@ -613,7 +653,7 @@ function M.format_document(content, options)
current_level = current_level + 1 current_level = current_level + 1
end end
end end
local formatted = string.rep(indent_char, current_level) .. trimmed local formatted = get_code_indent(current_level) .. trimmed
table.insert(formatted_lines, formatted) table.insert(formatted_lines, formatted)
table.insert(stack, { type = kw, indent = current_level }) table.insert(stack, { type = kw, indent = current_level })
if kw == "IF" or kw == "ELSIF" then if kw == "IF" or kw == "ELSIF" then
@@ -621,33 +661,24 @@ function M.format_document(content, options)
if not trimmed:match("THEN%s*$") then if not trimmed:match("THEN%s*$") then
in_condition = true in_condition = true
-- Continuation lines should be indented one level deeper than IF/ELSIF -- Continuation lines should be indented one level deeper than IF/ELSIF
condition_indent = string.rep(indent_char, current_level + 1) condition_indent = get_code_indent(current_level + 1)
end end
end end
goto continue goto continue
end end
-- Regular line - check for assignment start -- Regular line - check for assignment start
-- But not if its inside an attribute block, exiting one, or a single-line attribute block if in_code_body and not in_attr_block and not exiting_attr_block and trimmed:match(":=") and not trimmed:match("^{.*}$") then
if not in_attr_block and not exiting_attr_block and trimmed:match(":=") and not trimmed:match("^{.*}$") then
local base_indent = get_indent()
local assign_pos = trimmed:find(":=")
if assign_pos then
local var_part = trimmed:sub(1, assign_pos - 1)
local var_trimmed = var_part:match("^(.-)%s*$")
assignment_continuation_indent = base_indent .. string.rep(" ", #var_trimmed + 4)
if trimmed:match(";%s*$") then if trimmed:match(";%s*$") then
in_assignment = false in_assignment = false
else else
in_assignment = true in_assignment = true
end assignment_continuation_indent = get_indent()
end end
else else
in_assignment = false in_assignment = false
end end
-- Also reset assignment state when in attribute block
if in_attr_block then if in_attr_block then
in_assignment = false in_assignment = false
end end
+25 -9
View File
@@ -71,6 +71,9 @@ local capabilities = {
workspaceSymbolProvider = true, workspaceSymbolProvider = true,
documentFormattingProvider = true, documentFormattingProvider = true,
documentRangeFormattingProvider = false, documentRangeFormattingProvider = false,
executeCommandProvider = {
commands = { "SCLRescanWorkspaceTypes" },
},
} }
local function uri_to_path(uri) local function uri_to_path(uri)
@@ -831,11 +834,10 @@ function handlers.textDocument_completion(params)
end end
end end
if trigger == "#" or has_hash_prefix then if trigger == "#" then
if doc.variables then if doc.variables then
local prefix = has_hash_prefix and line_before:match("#([%w_]*)$") or word_prefix
for name, info in pairs(doc.variables) do for name, info in pairs(doc.variables) do
if prefix == "" or name:upper():sub(1, #prefix) == prefix:upper() then if word_prefix == "" or name:upper():sub(1, #word_prefix) == word_prefix:upper() then
table.insert(items, { table.insert(items, {
label = "#" .. name, label = "#" .. name,
filterText = "#" .. name, filterText = "#" .. name,
@@ -848,11 +850,16 @@ function handlers.textDocument_completion(params)
end end
end end
elseif trigger == "." then elseif trigger == "." then
local prefix_start = col - 1 local scan_end = col
local c = col > 0 and line:sub(col, col) or ""
if c == "" or c == "." then
scan_end = col - 1
end
local prefix_start = scan_end
while prefix_start > 0 and line:sub(prefix_start, prefix_start):match("[%w_]") do while prefix_start > 0 and line:sub(prefix_start, prefix_start):match("[%w_]") do
prefix_start = prefix_start - 1 prefix_start = prefix_start - 1
end end
local prefix = line:sub(prefix_start + 1, col - 1) local prefix = scan_end > prefix_start and line:sub(prefix_start + 1, scan_end) or ""
if prefix ~= "" then if prefix ~= "" then
local var_name = prefix:gsub("^#", "") local var_name = prefix:gsub("^#", "")
@@ -1798,16 +1805,25 @@ function handlers.textDocument_formatting(params)
return nil return nil
end end
local options = params.options or {}
local format_options = { local format_options = {
insertSpaces = options.insertSpaces, insertSpaces = false,
tabSize = options.tabSize, tabSize = 1,
indentSize = options.tabSize or 4, indentSize = 1,
} }
return formatter.format_document(doc.content, format_options) return formatter.format_document(doc.content, format_options)
end end
function handlers.workspace_executeCommand(params)
if params.command ~= "SCLRescanWorkspaceTypes" then
return nil
end
plc_json.clear_cache()
return {}
end
local function handle_message(message) local function handle_message(message)
if message.method == "$/cancelRequest" then if message.method == "$/cancelRequest" then
return nil return nil
+1 -1
View File
@@ -127,7 +127,7 @@ local function parse_scl_type_file(content)
fb_inouts = {} fb_inouts = {}
current_fb_section = nil current_fb_section = nil
elseif in_fb and trimmed:match("^END_FUNCTION_BLOCK") then elseif in_fb and trimmed:match("^END_FUNCTION_BLOCK") then
if fb_name and (#fb_inputs > 0 or #fb_outputs > 0 or #fb_inouts > 0) then if fb_name then
types[fb_name] = { types[fb_name] = {
name = fb_name, name = fb_name,
kind = "function_block", kind = "function_block",