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
# 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
```
@@ -152,7 +152,7 @@ The LSP validates data types and provides warnings for unknown types:
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:
+17 -6
View File
@@ -58,14 +58,25 @@ make 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:
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" }`
3. `:MasonInstall tia-lsp`
### Manual Setup
```bash
# 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
+61 -20
View File
@@ -106,6 +106,12 @@ function M.validate_diagnostics(content, workspace_types, root_dir)
ExternalWRITABLE = true, -- case variant
ExternalVISIBLE = 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_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"
end
if not in_var_section then
local hash_vars = {}
for var in line:gmatch("#[%w_]+") do
local var_name = var:sub(2)
local match_start = line:find(var, 1, true)
if not is_iec_literal_at(line, match_start) then
hash_vars[var_name] = true
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
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",
})
if not in_var_section then
if not (trimmed:match("^%s*//") or trimmed:match("^%s*%(%*")) then
local hash_vars = {}
for var in line:gmatch("#[%w_]+") do
local var_name = var:sub(2)
local match_start = line:find(var, 1, true)
if not is_iec_literal_at(line, match_start) and not is_number_literal_at(line, match_start) then
hash_vars[var_name] = true
end
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
@@ -215,8 +231,13 @@ function M.validate_diagnostics(content, workspace_types, root_dir)
if type_part:match("%s+OF%s+") then
base_type = type_part:match('.*%s+OF%s+"([^"]+)"') or type_part:match(".*%s+OF%s+([%w_]+)")
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
if base_type and base_type ~= "" and
not is_array_keyword and
not all_types[base_type] and
not builtin.is_known_data_type(upper_type) and
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
-- Skip comments
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
local var_name = assignment:match("#([%w_]+)")
if var_name and in_var_section then
+76 -45
View File
@@ -43,6 +43,7 @@ local BLOCKS = {
["FUNCTION"] = "END_FUNCTION",
["FUNCTION_BLOCK"] = "END_FUNCTION_BLOCK",
["ORGANIZATION_BLOCK"] = "END_ORGANIZATION_BLOCK",
["DATA_BLOCK"] = "END_DATA_BLOCK",
["TYPE"] = "END_TYPE",
["STRUCT"] = "END_STRUCT",
["VAR"] = "END_VAR",
@@ -72,7 +73,6 @@ local NEEDS_SEMICOLON = {
["END_WHILE"] = true,
["END_REPEAT"] = true,
["END_CASE"] = true,
["END_REGION"] = true,
}
local VAR_KEYWORDS = {
@@ -92,6 +92,7 @@ local DECLARATIONS = {
["FUNCTION"] = true,
["FUNCTION_BLOCK"] = true,
["ORGANIZATION_BLOCK"] = true,
["DATA_BLOCK"] = true,
["TYPE"] = true,
["STRUCT"] = true,
}
@@ -111,9 +112,6 @@ local CONTINUATION_OPERATORS = {
--- @return table Array of TextEdit objects with range and newText
function M.format_document(content, options)
options = options or {}
local use_spaces = options.insertSpaces or false
local indent_size = options.tabSize or 1
local indent_char = use_spaces and string.rep(" ", indent_size) or "\t"
local collapse_attributes = options.collapseAttributes
if collapse_attributes == nil then
@@ -127,6 +125,11 @@ function M.format_document(content, options)
end
end
local bom = string.char(239, 187, 191)
if content:sub(1, 3) == bom then
content = content:sub(4)
end
local lines = {}
for line in content:gmatch("([^\n]*)\n") do
table.insert(lines, line)
@@ -141,15 +144,29 @@ function M.format_document(content, options)
local stack = {}
local formatted_lines = {}
local in_code_body = false
local in_var_block = false
local function get_indent()
local level = 0
for _, block in ipairs(stack) do
if not block.no_indent then
level = level + 1
if in_code_body then
local level = 0
for _, block in ipairs(stack) do
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
return string.rep(indent_char, level)
end
local function get_code_indent(level)
return "\t" .. string.rep(" ", level)
end
local function is_assignment_continuation(trimmed)
@@ -261,6 +278,15 @@ function M.format_document(content, options)
table.insert(params, last_param)
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
return { base_indent .. fb_name .. "();" }
end
@@ -276,7 +302,7 @@ function M.format_document(content, options)
end
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
local function extract_variable_name(line)
@@ -489,7 +515,7 @@ function M.format_document(content, options)
if DECLARATIONS[kw] then
stack = {}
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
end
@@ -507,25 +533,38 @@ function M.format_document(content, options)
while #stack > 0 and VAR_KEYWORDS[stack[#stack].type] do
table.remove(stack)
end
table.insert(formatted_lines, get_indent() .. trimmed)
in_var_block = false
in_code_body = true
table.insert(formatted_lines, trimmed)
goto continue
end
if VAR_KEYWORDS[kw] then
while #stack > 0 and not stack[#stack].is_declaration do
table.remove(stack)
if in_code_body then
if VAR_KEYWORDS[kw] then
table.insert(formatted_lines, get_indent() .. trimmed)
table.insert(stack, { type = kw, indent = #stack, is_var = true })
goto continue
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
while #stack > 0 and VAR_KEYWORDS[stack[#stack].type] do
table.remove(stack)
if kw == "END_VAR" then
while #stack > 0 and VAR_KEYWORDS[stack[#stack].type] do
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
table.insert(formatted_lines, get_indent() .. trimmed)
goto continue
end
if kw and BLOCKS[kw] == nil then
@@ -556,7 +595,7 @@ function M.format_document(content, options)
break
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
table.insert(formatted_lines, then_indent .. trimmed)
table.insert(stack, { type = "THEN_CONTENT", indent = #stack, no_indent = true })
@@ -573,7 +612,7 @@ function M.format_document(content, options)
end
end
if if_block then
formatted = string.rep(indent_char, if_block.indent) .. trimmed
formatted = get_code_indent(if_block.indent) .. trimmed
else
formatted = get_indent() .. trimmed
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)
if not trimmed:match("THEN%s*$") then
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
else
formatted = get_indent() .. trimmed
@@ -597,9 +636,10 @@ function M.format_document(content, options)
while #stack > case_idx do
table.remove(stack)
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(stack, { type = "CASE_LABEL", indent = case_block.indent + 1, no_indent = true })
table.insert(stack, { type = "CASE_CONTENT", indent = case_block.indent + 2 })
else
table.insert(formatted_lines, get_indent() .. trimmed)
end
@@ -613,7 +653,7 @@ function M.format_document(content, options)
current_level = current_level + 1
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(stack, { type = kw, indent = current_level })
if kw == "IF" or kw == "ELSIF" then
@@ -621,33 +661,24 @@ function M.format_document(content, options)
if not trimmed:match("THEN%s*$") then
in_condition = true
-- 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
goto continue
end
-- Regular line - check for assignment start
-- But not if its inside an attribute block, exiting one, or a single-line attribute block
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
in_assignment = false
else
in_assignment = true
end
if in_code_body and not in_attr_block and not exiting_attr_block and trimmed:match(":=") and not trimmed:match("^{.*}$") then
if trimmed:match(";%s*$") then
in_assignment = false
else
in_assignment = true
assignment_continuation_indent = get_indent()
end
else
in_assignment = false
end
-- Also reset assignment state when in attribute block
if in_attr_block then
in_assignment = false
end
+185 -26
View File
@@ -71,6 +71,9 @@ local capabilities = {
workspaceSymbolProvider = true,
documentFormattingProvider = true,
documentRangeFormattingProvider = false,
executeCommandProvider = {
commands = { "SCLRescanWorkspaceTypes" },
},
}
local function uri_to_path(uri)
@@ -717,6 +720,72 @@ function handlers.textDocument_hover(params)
return nil
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
--- Provides completion items based on context (VAR section, member access, etc.)
--- @param params table Contains textDocument with uri and position
@@ -738,24 +807,28 @@ function handlers.textDocument_completion(params)
if line_idx <= #lines then
local line = lines[line_idx]
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 ""
-- 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
prefix_start = prefix_start - 1
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
for i = line_idx - 1, 1, -1 do
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
end
if prev_line:match("^VAR%s*$") or prev_line:match("^VAR$") then
if prev_line:match("^VAR") then
in_var_section = true
break
end
@@ -764,21 +837,29 @@ function handlers.textDocument_completion(params)
if trigger == "#" then
if doc.variables then
for name, info in pairs(doc.variables) do
table.insert(items, {
label = "#" .. name,
kind = 13,
detail = "Local variable (" .. (info.type or "unknown") .. ")",
insertText = "#" .. name,
insertTextFormat = 1,
})
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
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
prefix_start = prefix_start - 1
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
local var_name = prefix:gsub("^#", "")
@@ -837,18 +918,45 @@ function handlers.textDocument_completion(params)
end
end
elseif trigger == "(" then
if doc.functions then
for _, func in ipairs(doc.functions) do
-- Try get_call_context first (handles case where ( is already in the line)
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, {
label = func.name,
kind = 3,
detail = func.kind:upper(),
insertText = func.name .. "()",
insertTextFormat = 2,
label = "#" .. name,
filterText = "#" .. name,
kind = 13,
detail = "Variable (" .. (info.type or "VAR") .. "): " .. (info.data_type or "unknown"),
insertText = "#" .. name,
insertTextFormat = 1,
})
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
-- 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
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")
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
-- General context: show all
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
add_item(func_name, 3, "Function")
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
@@ -1655,16 +1805,25 @@ function handlers.textDocument_formatting(params)
return nil
end
local options = params.options or {}
local format_options = {
insertSpaces = options.insertSpaces,
tabSize = options.tabSize,
indentSize = options.tabSize or 4,
insertSpaces = false,
tabSize = 1,
indentSize = 1,
}
return formatter.format_document(doc.content, format_options)
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)
if message.method == "$/cancelRequest" then
return nil
+1 -1
View File
@@ -127,7 +127,7 @@ local function parse_scl_type_file(content)
fb_inouts = {}
current_fb_section = nil
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] = {
name = fb_name,
kind = "function_block",