20 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
lazar dae99fb33e fix: add InstructionName, LibVersion, LibName to allowed VAR attributes
SCL005 false positive triggered on:
  timers{InstructionName := 'IEC_TIMER'; LibVersion := '1.0'}
These are valid TIA Portal attributes for IEC timer variables.
2026-07-22 16:55:32 +02:00
lazar 8d5bf9f99f fix: return full item set on ( trigger to survive auto-close
When LazyVim auto-close inserts () after (, nvim-cmp sees ) as the
last character and may suppress the popup. By returning variables,
builtins, and data types alongside the function params, the response
is rich enough that the next keystroke (e.g. typing I after auto-close)
triggers a fresh TextChanged completion that re-detects the call
context via get_call_context in the else branch.
2026-07-22 15:30:30 +02:00
lazar 2184007cf6 fix: find with plain=true makes ^ literal, breaking all prefix matching
Nine sites used name:upper():find('^' .. prefix:upper(), 1, true).
With plain=true, '^' is treated as a literal character, so the prefix
was never found and no variables/params ever matched the typed text.

Replaced all with substring comparison:
  name:upper():sub(1, #prefix) == prefix:upper()

This fixes:
- Variable completion without # prefix (instTestTimer never matched)
- Function parameter filtering by typed prefix (IN never matched IN)
- # trigger variable filtering
2026-07-22 15:09:53 +02:00
lazar 3abb0f5d9a fix: remove # from label in non-hash completion paths
- When user types without # (e.g. instTestTimer), the label was still
  #instTestTimer, so client-side filtering rejected the match.
- Now label = name (bare) in general/var-section context, insertText
  still adds the # prefix for correct SCL insertion.
- # trigger branch sets filterText = "#" .. name to match the #-prefixed
  word the client sends after typing #.
2026-07-22 14:55:20 +02:00
lazar a33d15d238 fix: add filterText so variables match when typing without # prefix
Variable labels use # prefix (#instTestTimer) but users type without #
(instTestTimer). Without filterText, the client filters using the label
and #instTestTimer doesn't match instTestTimer, so the variable never
appears in autocomplete. Setting filterText to the bare variable name
fixes this.

Also removed aggressive fallback in else branch that treated any word
typed as function call context even without a ( in the line.
2026-07-22 14:19:02 +02:00
lazar eb62107066 fix: show function call parameters immediately on ( trigger
The word_prefix calculation was scanning backwards from col-1, so after
instTestTimer( it would pick up 'instTestTimer' and cause the client to
filter out all parameters (none start with the function name). Fixed to
scan from col so delimiters like ( stop the scan, giving word_prefix=''.

Also added fallback in else branch when ( is not yet in the document line
(timing issue where completion arrives before didChange is processed).
2026-07-22 13:56:08 +02:00
lazar 00cf1b0074 fix: show function/block parameters in autocomplete after typing ( 2026-07-22 13:47:11 +02:00
lazar 3ba1ccbb1b fix: suppress false SCL003 warning for ARRAY[...] type constructor 2026-07-20 14:52:21 +02:00
lazar 86b3f73daf fix: add all-caps EXTERNAL* variants to allowed VAR attributes
VCI-exported .scl files use all-caps attribute names (EXTERNALACCESSIBLE,
EXTERNALVISIBLE, EXTERNALWRITABLE) which were not in the allowed list,
triggering spurious SCL005 warnings.
2026-07-20 14:23:45 +02:00
lazar ef8a44520d docs: simplify mason section to cross-reference tia-lsp.nvim, add manual setup steps 2026-07-19 15:09:58 +02:00
6 changed files with 343 additions and 101 deletions
+2 -2
View File
@@ -24,7 +24,7 @@ lua test/test_formatter.lua # Run formatter tests
lua test/test_plc_json.lua # Run plc_json tests lua test/test_plc_json.lua # Run plc_json tests
# Reference Project # Reference Project
# Tests use files from: ~/dev/siemens/projects/scl_lang_support_lazyvim_ref_project/ # Tests use files from: ~/Documents/siemens/scl_lang_support_lazyvim_ref_project/
# Override with: SCL_REF_PROJECT=/path/to/ref lua tests/run_tests.lua # Override with: SCL_REF_PROJECT=/path/to/ref lua tests/run_tests.lua
``` ```
@@ -152,7 +152,7 @@ The LSP validates data types and provides warnings for unknown types:
Use the reference project for testing: Use the reference project for testing:
``` ```
~/dev/siemens/projects/scl_lang_support_lazyvim_ref_project/ ~/Documenta/siemens/scl_lang_support_lazyvim_ref_project/
``` ```
Contains `.scl`, `.db`, `.udt`, and `.xml` files for comprehensive testing of: Contains `.scl`, `.db`, `.udt`, and `.xml` files for comprehensive testing of:
+17 -6
View File
@@ -58,14 +58,25 @@ make test
# or: lua src/main.lua --test # or: lua src/main.lua --test
``` ```
### Mason Installation (Neovim) ### Neovim + Mason (Recommended)
For Neovim users, install via [mason.nvim](https://github.com/mason-org/mason.nvim) using a custom registry. See the [`tia-lsp.nvim` README](https://gitea.l-tech.rs/lazar/tia-lsp.nvim) for full setup instructions. The recommended way is via [mason.nvim](https://github.com/mason-org/mason.nvim) using the
[`tia-mason-registry`](https://gitea.l-tech.rs/lazar/tia-mason-registry). See the
[`tia-lsp.nvim` README](https://gitea.l-tech.rs/lazar/tia-lsp.nvim) for full step-by-step instructions.
Quick summary: ### Manual Setup
1. Add the `tia-mason-registry` as a lazy.nvim plugin
2. Register it with mason: `registries = { "file:~/.local/share/nvim/lazy/tia-mason-registry", "github:mason-org/mason-registry" }` ```bash
3. `:MasonInstall tia-lsp` # 1. Clone this repository
git clone --depth 1 https://gitea.l-tech.rs/lazar/tia-lsp.git ~/tia-lsp
# 2. Compile the tree-sitter parser
cc -fPIC -I ~/tia-lsp/src/tree_sitter -c ~/tia-lsp/src/parser.c -o ~/tia-lsp/parser.o
cc -shared ~/tia-lsp/parser.o -o ~/tia-lsp/parser.so
# 3. Start the server (or configure your editor to run this)
lua ~/tia-lsp/src/main.lua
```
## External UDT Support ## External UDT Support
+61 -20
View File
@@ -106,6 +106,12 @@ function M.validate_diagnostics(content, workspace_types, root_dir)
ExternalWRITABLE = true, -- case variant ExternalWRITABLE = true, -- case variant
ExternalVISIBLE = true, ExternalVISIBLE = true,
ExternalACCESSIBLE = true, ExternalACCESSIBLE = true,
EXTERNALWRITABLE = true, -- all-caps VCI export variant
EXTERNALVISIBLE = true, -- all-caps VCI export variant
EXTERNALACCESSIBLE = true,-- all-caps VCI export variant
InstructionName = true,
LibVersion = true,
LibName = true,
S7_SetPoint = true, S7_SetPoint = true,
S7_Optimized_Access = true, S7_Optimized_Access = true,
} }
@@ -132,27 +138,37 @@ 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
if not in_var_section then -- Check if hash is part of a base-specific number literal like 16#FFFF, 2#1010
local hash_vars = {} local function is_number_literal_at(line, match_start)
for var in line:gmatch("#[%w_]+") do if not line or not match_start then return false end
local var_name = var:sub(2) if match_start <= 1 then return false end
local match_start = line:find(var, 1, true) local before = line:sub(match_start - 1, match_start - 1)
if not is_iec_literal_at(line, match_start) then return before:match("%d") ~= nil
hash_vars[var_name] = true end
end
end
for var_name, _ in pairs(hash_vars) do if not in_var_section then
if not variables[var_name] and not all_types[var_name] then if not (trimmed:match("^%s*//") or trimmed:match("^%s*%(%*")) then
local var_start = line:find("#" .. var_name, 1, true) local hash_vars = {}
if var_start then for var in line:gmatch("#[%w_]+") do
table.insert(diagnostics, { local var_name = var:sub(2)
range = range_to_lsp(line_num, var_start - 1, line_num, var_start + #var_name - 1), local match_start = line:find(var, 1, true)
severity = DiagnosticSeverity.Error, if not is_iec_literal_at(line, match_start) and not is_number_literal_at(line, match_start) then
message = string.format("Undefined variable: %s", var_name), hash_vars[var_name] = true
code = "SCL001", end
source = "tia_lsp", end
})
for var_name, _ in pairs(hash_vars) do
if not variables[var_name] and not all_types[var_name] then
local var_start = line:find("#" .. var_name, 1, true)
if var_start then
table.insert(diagnostics, {
range = range_to_lsp(line_num, var_start - 1, line_num, var_start + #var_name - 1),
severity = DiagnosticSeverity.Error,
message = string.format("Undefined variable: %s", var_name),
code = "SCL001",
source = "tia_lsp",
})
end
end end
end end
end end
@@ -215,8 +231,13 @@ function M.validate_diagnostics(content, workspace_types, root_dir)
if type_part:match("%s+OF%s+") then if type_part:match("%s+OF%s+") then
base_type = type_part:match('.*%s+OF%s+"([^"]+)"') or type_part:match(".*%s+OF%s+([%w_]+)") base_type = type_part:match('.*%s+OF%s+"([^"]+)"') or type_part:match(".*%s+OF%s+([%w_]+)")
end end
local is_array_keyword = base_type and base_type:upper() == "ARRAY"
and type_part:match("^[Aa][Rr][Rr][Aa][Yy]%[")
local upper_type = base_type and base_type:upper() or nil local upper_type = base_type and base_type:upper() or nil
if base_type and base_type ~= "" and if base_type and base_type ~= "" and
not is_array_keyword and
not all_types[base_type] and not all_types[base_type] and
not builtin.is_known_data_type(upper_type) and not builtin.is_known_data_type(upper_type) and
not builtin.is_known_type_or_instruction(upper_type) then not builtin.is_known_type_or_instruction(upper_type) then
@@ -235,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
+76 -45
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()
local level = 0 if in_code_body then
for _, block in ipairs(stack) do local level = 0
if not block.no_indent then for _, block in ipairs(stack) do
level = level + 1 if not block.no_indent then
level = level + 1
end
end
return "\t" .. string.rep(" ", level)
else
if in_var_block then
return " "
else
return ""
end end
end end
return string.rep(indent_char, level) 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,25 +533,38 @@ 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 VAR_KEYWORDS[kw] then if in_code_body then
while #stack > 0 and not stack[#stack].is_declaration do if VAR_KEYWORDS[kw] then
table.remove(stack) table.insert(formatted_lines, get_indent() .. trimmed)
table.insert(stack, { type = kw, indent = #stack, is_var = true })
goto continue
end end
table.insert(formatted_lines, get_indent() .. trimmed)
table.insert(stack, { type = kw, indent = #stack, is_var = true })
goto continue
end
if kw == "END_VAR" then if kw == "END_VAR" then
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
table.insert(formatted_lines, get_indent() .. trimmed)
goto continue
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
table.insert(formatted_lines, get_indent() .. trimmed)
goto continue
end end
if kw and BLOCKS[kw] == nil then if kw and BLOCKS[kw] == nil then
@@ -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 if trimmed:match(";%s*$") then
local base_indent = get_indent() in_assignment = false
local assign_pos = trimmed:find(":=") else
if assign_pos then in_assignment = true
local var_part = trimmed:sub(1, assign_pos - 1) assignment_continuation_indent = get_indent()
local var_trimmed = var_part:match("^(.-)%s*$")
assignment_continuation_indent = base_indent .. string.rep(" ", #var_trimmed + 4)
if trimmed:match(";%s*$") then
in_assignment = false
else
in_assignment = true
end
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
+185 -26
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)
@@ -717,6 +720,72 @@ function handlers.textDocument_hover(params)
return nil return nil
end end
--- Detect if cursor is inside a function call's parentheses
--- Scans backwards from cursor to find an unmatched ( and returns the name before it
--- @param line string The current line content
--- @param col number Cursor column (0-indexed LSP position)
--- @return string|nil Function/block name, or nil if not in a call context
local function get_call_context(line, col)
if col <= 1 then return nil end
local before_cursor = line:sub(1, col)
local paren_pos = nil
for i = #before_cursor, 1, -1 do
local c = before_cursor:sub(i, i)
if c == ")" then break end
if c == "(" then paren_pos = i; break end
end
if not paren_pos then return nil end
local before_paren = before_cursor:sub(1, paren_pos - 1)
return before_paren:match("([%w_]+)%s*$")
end
--- Resolve a function/variable call name to its parameter info
--- Checks variables (for data_type resolution), built-in parameters, and workspace types
--- @param call_name string The name to resolve (e.g., "instTestTimer" or "TON")
--- @param variables table|nil Document variables table
--- @param types table|nil Document types table
--- @return table|nil Parameter definition with inputs and outputs arrays, or nil
local function resolve_call_params(call_name, variables, types)
if not call_name then return nil end
local clean_name = call_name:gsub("^#", "")
local data_type = variables and variables[clean_name] and variables[clean_name].data_type
local resolved_name = data_type or clean_name
local builtin_params = builtin.get_parameters(resolved_name)
if builtin_params then return builtin_params end
local type_info = types and types[resolved_name]
if type_info and type_info.fields then
return { inputs = type_info.fields, outputs = {} }
end
return nil
end
local function add_param_items(items, params, prefix_filter)
for _, inp in ipairs(params.inputs or {}) do
local label = inp.name .. " := "
if prefix_filter == "" or inp.name:upper():sub(1, #prefix_filter) == prefix_filter:upper() then
table.insert(items, {
label = label,
kind = 13,
detail = "Parameter (" .. (inp.type or "ANY") .. ")",
insertText = inp.name .. " := ",
insertTextFormat = 1,
})
end
end
for _, out in ipairs(params.outputs or {}) do
local label = out.name .. " => "
if prefix_filter == "" or out.name:upper():sub(1, #prefix_filter) == prefix_filter:upper() then
table.insert(items, {
label = label,
kind = 13,
detail = "Output (" .. (out.type or "ANY") .. ")",
insertText = out.name .. " => ",
insertTextFormat = 1,
})
end
end
end
--- Handle textDocument/completion request --- Handle textDocument/completion request
--- Provides completion items based on context (VAR section, member access, etc.) --- Provides completion items based on context (VAR section, member access, etc.)
--- @param params table Contains textDocument with uri and position --- @param params table Contains textDocument with uri and position
@@ -738,24 +807,28 @@ function handlers.textDocument_completion(params)
if line_idx <= #lines then if line_idx <= #lines then
local line = lines[line_idx] local line = lines[line_idx]
local col = params.position.character local col = params.position.character
local trigger = col > 0 and line:sub(col, col) or "" local trigger = (params.context and params.context.triggerCharacter) or (col > 0 and line:sub(col, col) or "")
local line_before = col > 1 and line:sub(1, col - 1) or "" local line_before = col > 1 and line:sub(1, col - 1) or ""
-- Get word prefix being typed (for filtering) -- Get word prefix being typed (for filtering)
local prefix_start = col - 1 -- Start from col (not col-1) so delimiters like ( stop the scan
local prefix_start = col
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 word_prefix = line:sub(prefix_start + 1, col - 1) local word_prefix = col > prefix_start and line:sub(prefix_start + 1, col) or ""
-- Check if we're in VAR section -- Check if typing after # prefix (e.g., #myVar)
local has_hash_prefix = line_before:match("#[%w_]*$")
-- Check if we're in VAR section (detects all VAR variants)
local in_var_section = false local in_var_section = false
for i = line_idx - 1, 1, -1 do for i = line_idx - 1, 1, -1 do
local prev_line = lines[i]:gsub("^%s+", ""):gsub("%s+$", "") local prev_line = lines[i]:gsub("^%s+", ""):gsub("%s+$", "")
if prev_line:match("^END_VAR") or prev_line:match("^VAR_TEMP") or prev_line:match("^VAR_IN_OUT") or prev_line:match("^VAR_OUTPUT") or prev_line:match("^VAR_INPUT") then if prev_line:match("^END_VAR") then
break break
end end
if prev_line:match("^VAR%s*$") or prev_line:match("^VAR$") then if prev_line:match("^VAR") then
in_var_section = true in_var_section = true
break break
end end
@@ -764,21 +837,29 @@ function handlers.textDocument_completion(params)
if trigger == "#" then if trigger == "#" then
if doc.variables then if doc.variables then
for name, info in pairs(doc.variables) do for name, info in pairs(doc.variables) do
table.insert(items, { if word_prefix == "" or name:upper():sub(1, #word_prefix) == word_prefix:upper() then
label = "#" .. name, table.insert(items, {
kind = 13, label = "#" .. name,
detail = "Local variable (" .. (info.type or "unknown") .. ")", filterText = "#" .. name,
insertText = "#" .. name, kind = 13,
insertTextFormat = 1, detail = "Variable (" .. (info.type or "VAR") .. "): " .. (info.data_type or "unknown"),
}) insertText = "#" .. name,
insertTextFormat = 1,
})
end
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("^#", "")
@@ -837,18 +918,45 @@ function handlers.textDocument_completion(params)
end end
end end
elseif trigger == "(" then elseif trigger == "(" then
if doc.functions then -- Try get_call_context first (handles case where ( is already in the line)
for _, func in ipairs(doc.functions) do local call_name = get_call_context(line, col)
-- Fallback: ( may not be in the line yet (client sends completion before didChange)
if not call_name and col > 0 then
call_name = line:sub(1, col):match("([%w_]+)%s*$")
end
local params = call_name and resolve_call_params(call_name, doc.variables, doc.types)
if params then
add_param_items(items, params, "")
end
-- Also add variables and builtins so completion popup stays rich
-- even if auto-close ( or client-side filtering interferes
if doc.variables then
for name, info in pairs(doc.variables) do
table.insert(items, { table.insert(items, {
label = func.name, label = "#" .. name,
kind = 3, filterText = "#" .. name,
detail = func.kind:upper(), kind = 13,
insertText = func.name .. "()", detail = "Variable (" .. (info.type or "VAR") .. "): " .. (info.data_type or "unknown"),
insertTextFormat = 2, insertText = "#" .. name,
insertTextFormat = 1,
}) })
end end
end end
for type_name, _ in pairs(builtin.DATA_TYPES) do
table.insert(items, { label = type_name, kind = 7, detail = "Data type", insertText = type_name, insertTextFormat = 1 })
end
for fb_name, _ in pairs(builtin.FUNCTION_BLOCKS) do
table.insert(items, { label = fb_name, kind = 6, detail = "Function block", insertText = fb_name, insertTextFormat = 1 })
end
for func_name, _ in pairs(builtin.FUNCTIONS) do
table.insert(items, { label = func_name, kind = 3, detail = "Function", insertText = func_name, insertTextFormat = 1 })
end
else else
-- Check if we're inside a function call (e.g., after instTestTimer()
local call_name = get_call_context(line, col)
local call_params = call_name and resolve_call_params(call_name, doc.variables, doc.types)
-- Check context before cursor -- Check context before cursor
local is_var_decl = line_before:match(":%s*$") local is_var_decl = line_before:match(":%s*$")
@@ -881,6 +989,27 @@ function handlers.textDocument_completion(params)
add_item(type_name, 7, type_info.kind or "Type") add_item(type_name, 7, type_info.kind or "Type")
end end
end end
-- Add declared variables (label without # so client-side matching works)
if doc.variables then
for name, info in pairs(doc.variables) do
if word_prefix == "" or name:upper():sub(1, #word_prefix) == word_prefix:upper() then
table.insert(items, {
label = name,
filterText = name,
kind = 13,
detail = "Variable (" .. (info.type or "VAR") .. "): " .. (info.data_type or "unknown"),
insertText = "#" .. name,
insertTextFormat = 1,
})
end
end
end
-- Add function/block call parameters if inside (...) context
if call_params then
add_param_items(items, call_params, word_prefix)
end
else else
-- General context: show all -- General context: show all
for type_name, _ in pairs(builtin.DATA_TYPES) do for type_name, _ in pairs(builtin.DATA_TYPES) do
@@ -894,6 +1023,27 @@ function handlers.textDocument_completion(params)
for func_name, _ in pairs(builtin.FUNCTIONS) do for func_name, _ in pairs(builtin.FUNCTIONS) do
add_item(func_name, 3, "Function") add_item(func_name, 3, "Function")
end end
-- Add declared variables in general context too
if doc.variables then
for name, info in pairs(doc.variables) do
if word_prefix == "" or name:upper():sub(1, #word_prefix) == word_prefix:upper() then
table.insert(items, {
label = name,
filterText = name,
kind = 13,
detail = "Variable (" .. (info.type or "VAR") .. "): " .. (info.data_type or "unknown"),
insertText = "#" .. name,
insertTextFormat = 1,
})
end
end
end
-- Add function/block call parameters if inside (...) context
if call_params then
add_param_items(items, call_params, word_prefix)
end
end end
end end
end end
@@ -1655,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",