7 Commits
Author SHA1 Message Date
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
3 changed files with 169 additions and 23 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:
+3
View File
@@ -109,6 +109,9 @@ function M.validate_diagnostics(content, workspace_types, root_dir)
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,
}
+164 -21
View File
@@ -717,6 +717,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,39 +804,47 @@ 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
end
if trigger == "#" then
if trigger == "#" or has_hash_prefix 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
table.insert(items, {
label = "#" .. name,
kind = 13,
detail = "Local variable (" .. (info.type or "unknown") .. ")",
insertText = "#" .. name,
insertTextFormat = 1,
})
if prefix == "" or name:upper():sub(1, #prefix) == 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
@@ -837,18 +911,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 +982,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 +1016,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