Compare commits
17
Commits
v0.1.6
...
b4293b4f30
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b4293b4f30 | ||
|
|
6fbce2af6b | ||
|
|
909354e71a | ||
|
|
d24e83a1a7 | ||
|
|
ebdc96316a | ||
|
|
3ca63d278b | ||
|
|
8b400054c6 | ||
|
|
cce4059b7e | ||
|
|
5205819201 | ||
|
|
73735192bd | ||
|
|
dae99fb33e | ||
|
|
8d5bf9f99f | ||
|
|
2184007cf6 | ||
|
|
3abb0f5d9a | ||
|
|
a33d15d238 | ||
|
|
eb62107066 | ||
|
|
00cf1b0074 |
@@ -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:
|
||||||
|
|||||||
+53
-20
@@ -109,6 +109,9 @@ function M.validate_diagnostics(content, workspace_types, root_dir)
|
|||||||
EXTERNALWRITABLE = true, -- all-caps VCI export variant
|
EXTERNALWRITABLE = true, -- all-caps VCI export variant
|
||||||
EXTERNALVISIBLE = true, -- all-caps VCI export variant
|
EXTERNALVISIBLE = true, -- all-caps VCI export variant
|
||||||
EXTERNALACCESSIBLE = 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,
|
||||||
}
|
}
|
||||||
@@ -135,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
|
||||||
@@ -243,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
@@ -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
@@ -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
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user